diff --git a/Cargo.lock b/Cargo.lock index bd27ec2..75ca320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,6 +1140,7 @@ dependencies = [ "anchor-client", "anchor-lang", "base64 0.13.1", + "bincode", "borsh 1.4.0", "bytemuck", "bytes", @@ -1159,7 +1160,9 @@ dependencies = [ "reqwest 0.12.2", "serde", "serde_json", + "serde_qs", "solana-account-decoder", + "solana-address-lookup-table-program", "solana-client", "solana-sdk", "solana-transaction-status", @@ -3302,6 +3305,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_qs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index 39d6b09..a95b600 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ fnv = "1.0.7" futures-util = "0.3.29" jit-proxy = { git = "https://github.com/drift-labs/jit-proxy", optional = true } log = "0.4.20" -reqwest = "*" +reqwest = { version = "*", features = ["json"] } serde = { version = "*", features = ["derive"] } serde_json = "*" solana-account-decoder = "1.14" @@ -44,6 +44,9 @@ tokio-tungstenite = { version = "0.21.0", features = ["native-tls"] } regex = "1.10.2" dashmap = "5.5.3" rayon = "1.9.0" +bincode = "1.3.3" +serde_qs = "0.13.0" +solana-address-lookup-table-program = "1.14" [dev-dependencies] pyth = { git = "https://github.com/drift-labs/protocol-v2.git", tag = "v2.67.0", features = [ diff --git a/src/jupiter/mod.rs b/src/jupiter/mod.rs new file mode 100644 index 0000000..8511680 --- /dev/null +++ b/src/jupiter/mod.rs @@ -0,0 +1,458 @@ +use std::{collections::HashMap, str::FromStr}; + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use solana_client::{nonblocking::rpc_client::RpcClient, rpc_config::RpcAccountInfoConfig}; +use solana_sdk::{ + account::ReadableAccount, + address_lookup_table_account::AddressLookupTableAccount, + instruction::Instruction, + message::{v0, VersionedMessage}, + pubkey::Pubkey, + signature::Keypair, + signer::Signer, + transaction::VersionedTransaction, +}; + +use crate::{ + jupiter::serde_helpers::field_as_string, + types::{SdkError, SdkResult}, +}; + +use self::{ + swap::{SwapInstructionsResponse, SwapInstructionsResponseInternal, SwapRequest, SwapResponse}, + transaction_config::TransactionConfig, +}; + +mod serde_helpers; +mod swap; +mod transaction_config; + +#[derive(Serialize, Deserialize, Default, PartialEq, Clone, Debug)] +pub enum SwapMode { + #[default] + ExactIn, + ExactOut, +} + +impl FromStr for SwapMode { + type Err = SdkError; + + fn from_str(s: &str) -> std::result::Result { + match s { + "ExactIn" => Ok(Self::ExactIn), + "ExactOut" => Ok(Self::ExactOut), + _ => Err(SdkError::Generic(format!("{} is not a valid SwapMode", s))), + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RoutePlanStep { + pub swap_info: SwapInfo, + pub percent: u8, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SwapInfo { + #[serde(with = "field_as_string")] + pub amm_key: Pubkey, + pub label: String, + #[serde(with = "field_as_string")] + pub input_mint: Pubkey, + #[serde(with = "field_as_string")] + pub output_mint: Pubkey, + /// An estimation of the input amount into the AMM + #[serde(with = "field_as_string")] + pub in_amount: u64, + /// An estimation of the output amount into the AMM + #[serde(with = "field_as_string")] + pub out_amount: u64, + #[serde(with = "field_as_string")] + pub fee_amount: u64, + #[serde(with = "field_as_string")] + pub fee_mint: Pubkey, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlatformFee { + #[serde(with = "field_as_string")] + pub amount: u64, + pub fee_bps: u8, +} + +#[derive(Serialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct QuoteRequest { + #[serde(with = "field_as_string")] + pub input_mint: Pubkey, + #[serde(with = "field_as_string")] + pub output_mint: Pubkey, + #[serde(with = "field_as_string")] + pub amount: u64, + pub swap_mode: Option, + /// Allowed slippage in basis points + pub slippage_bps: u16, + /// Platform fee in basis points + pub platform_fee_bps: Option, + pub dexes: Option>, + pub excluded_dexes: Option>, + /// Quote only direct routes + pub only_direct_routes: Option, + /// Quote fit into legacy transaction + pub as_legacy_transaction: Option, + /// Find a route given a maximum number of accounts involved, + /// this might dangerously limit routing ending up giving a bad price. + /// The max is an estimation and not the exact count + pub max_accounts: Option, + // Quote type to be used for routing, switches the algorithm + pub quote_type: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct QuoteResponse { + #[serde(with = "field_as_string")] + pub input_mint: Pubkey, + #[serde(with = "field_as_string")] + pub in_amount: u64, + #[serde(with = "field_as_string")] + pub output_mint: Pubkey, + #[serde(with = "field_as_string")] + pub out_amount: u64, + /// Not used by build transaction + #[serde(with = "field_as_string")] + pub other_amount_threshold: u64, + pub swap_mode: SwapMode, + pub slippage_bps: u16, + pub platform_fee: Option, + pub price_impact_pct: String, + pub route_plan: Vec, + #[serde(default)] + pub context_slot: u64, + #[serde(default)] + pub time_taken: f64, +} + +pub struct JupiterClient { + url: String, + rpc_client: RpcClient, + lookup_table_cache: HashMap, +} + +impl JupiterClient { + pub fn new(rpc_client: RpcClient, url: Option) -> Self { + let url = match url { + Some(url) => url, + None => "https://quote-api.jup.ag".to_string(), + }; + + Self { + url, + rpc_client, + lookup_table_cache: HashMap::new(), + } + } + + /// Get routes for a swap + #[allow(clippy::too_many_arguments)] + pub async fn get_quote( + &self, + input_mint: Pubkey, + output_mint: Pubkey, + amount: u64, + max_accounts: Option, + slippage_bps: u16, + swap_mode: Option, + only_direct_routes: Option, + excluded_dexes: Option>, + ) -> SdkResult { + let quote_request = QuoteRequest { + input_mint, + output_mint, + amount, + swap_mode, + slippage_bps, + platform_fee_bps: None, + dexes: None, + excluded_dexes, + only_direct_routes, + as_legacy_transaction: None, + max_accounts, + quote_type: None, + }; + let query = serde_qs::to_string("e_request) + .map_err(|e| SdkError::Generic(format!("failed to serialize: {e}")))?; + let api_version_param = if self.url == "https://quote-api.jup.ag" { + "/v6" + } else { + "" + }; + + let response = Client::new() + .get(format!("{}{api_version_param}/quote?{query}", self.url)) + .send() + .await?; + + if response.status().is_success() { + Ok(response + .json::() + .await + .map_err(|e| SdkError::Generic(format!("failed to get json: {e}")))?) + } else { + Err(SdkError::Generic(format!( + "Request status not ok: {}, body: {}", + response.status(), + response + .text() + .await + .map_err(|e| SdkError::Generic(format!("failed to get text: {e}")))? + ))) + } + } + + /// Get a swap transaction for quote + pub async fn get_swap( + &self, + mut quote_response: QuoteResponse, + user_public_key: Pubkey, + slippage_bps: Option, + ) -> SdkResult { + let slippage_bps = slippage_bps.unwrap_or(50); + let api_version_param = if self.url == "https://quote-api.jup.ag" { + "/v6" + } else { + "" + }; + + quote_response.slippage_bps = slippage_bps; + let swap_request = SwapRequest { + user_public_key, + quote_response, + config: TransactionConfig::default(), + }; + let response = Client::new() + .post(format!("{}{api_version_param}/swap", self.url)) + .json(&swap_request) + .send() + .await?; + + if response.status().is_success() { + let res = response + .json::() + .await + .map_err(|e| SdkError::Generic(format!("failed to get json: {e}")))?; + + let versioned_transaction: VersionedTransaction = + bincode::deserialize(&res.swap_transaction) + .map_err(|_e| SdkError::Deserializing)?; + + Ok(versioned_transaction) + } else { + Err(SdkError::Generic(format!( + "Request status not ok: {}, body: {}", + response.status(), + response + .text() + .await + .map_err(|e| SdkError::Generic(format!("failed to get text: {e}")))? + ))) + } + } + + /// Get the transaction message and lookup tables for a transaction + /// https://solana.stackexchange.com/questions/12811/lookuptables-in-rust + pub async fn get_transaction_message_and_lookup_tables( + &self, + transaction: VersionedTransaction, + instruction: Instruction, + payer: &Keypair, + ) -> SdkResult<(VersionedTransaction, Vec)> { + let message = transaction.message; + + let lookup_tables_futures = match message.address_table_lookups() { + Some(lookups) => lookups + .iter() + .map(|lookup| self.get_lookup_table(lookup.account_key)) + .collect(), + None => vec![], + }; + + let lookup_tables: Vec = + futures_util::future::join_all(lookup_tables_futures) + .await + .into_iter() + .filter_map(|result| match result { + Ok(Some(account)) => Some(account), + _ => None, + }) + .collect(); + + let recent_blockhash = self.rpc_client.get_latest_blockhash().await?; + let tx = VersionedTransaction::try_new( + VersionedMessage::V0( + v0::Message::try_compile( + &payer.pubkey(), + &[instruction], + &lookup_tables, + recent_blockhash, + ) + .map_err(|e| SdkError::Generic(format!("failed to compile: {e}")))?, + ), + &[payer], + )?; + + Ok((tx, lookup_tables)) + } + + async fn get_lookup_table( + &self, + account_key: Pubkey, + ) -> SdkResult> { + if let Some(table_account) = self.lookup_table_cache.get(&account_key.to_string()) { + return Ok(Some(table_account.clone())); + } + + let account_info = self + .rpc_client + .get_account_with_config(&account_key, RpcAccountInfoConfig::default()) + .await?; + + let mut value = None; + if let Some(account) = account_info.value { + let table = + solana_address_lookup_table_program::state::AddressLookupTable::deserialize( + account.data(), + ) + .map_err(|_e| SdkError::Deserializing)?; + value = Some(AddressLookupTableAccount { + key: account_key, + addresses: table.addresses.to_vec(), + }); + } + + Ok(value) + } + + pub async fn get_swap_instructions( + &self, + quote_response: QuoteResponse, + user_public_key: Pubkey, + ) -> SdkResult { + let api_version_param = if self.url == "https://quote-api.jup.ag" { + "/v6" + } else { + "" + }; + + let swap_request = SwapRequest { + user_public_key, + quote_response, + config: TransactionConfig::default(), + }; + let response = Client::new() + .post(format!("{}{api_version_param}/swap-instructions", self.url)) + .json(&swap_request) + .send() + .await?; + + if response.status().is_success() { + let swap_instruction_res_internal = response + .json::() + .await + .map_err(|e| SdkError::Generic(format!("failed to get json: {e}")))?; + + Ok(swap_instruction_res_internal.into()) + } else { + Err(SdkError::Generic(format!( + "Request status not ok: {}, body: {}", + response.status(), + response + .text() + .await + .map_err(|e| SdkError::Generic(format!("failed to get text: {e}")))? + ))) + } + } +} + +#[cfg(test)] +mod tests { + use solana_client::nonblocking::rpc_client::RpcClient; + use solana_sdk::pubkey; + use solana_sdk::pubkey::Pubkey; + + use crate::jupiter::JupiterClient; + use crate::types::SdkResult; + + use super::QuoteResponse; + + const USDC_MINT: Pubkey = pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + const NATIVE_MINT: Pubkey = pubkey!("So11111111111111111111111111111111111111112"); + const TEST_WALLET: Pubkey = pubkey!("2AQdpHJ2JpcEgPiATUXjQxA8QmafFegfQwSLWSprPicm"); + + async fn request_get_quote(client: &JupiterClient) -> SdkResult { + let quote_response = client + .get_quote( + USDC_MINT, + NATIVE_MINT, + 1_000_000, + None, + 50, + None, + None, + None, + ) + .await; + + quote_response + } + + #[tokio::test] + async fn test_get_quote() { + let rpc_client = RpcClient::new("".to_string()); + let jupiter_client = JupiterClient::new(rpc_client, None); + + // GET /quote + let quote_response = request_get_quote(&jupiter_client).await; + + assert!(quote_response.is_ok()); + } + + #[tokio::test] + async fn test_get_swap() { + let rpc_client = RpcClient::new("".to_string()); + let jupiter_client = JupiterClient::new(rpc_client, None); + + let quote_response = request_get_quote(&jupiter_client) + .await + .expect("failed to get quote"); + + // POST /swap + let swap_response = jupiter_client + .get_swap(quote_response, TEST_WALLET, None) + .await; + + assert!(swap_response.is_ok()); + } + + #[tokio::test] + async fn test_get_swap_instructions() { + let rpc_client = RpcClient::new("".to_string()); + let jupiter_client = JupiterClient::new(rpc_client, None); + + let quote_response = request_get_quote(&jupiter_client) + .await + .expect("failed to get quote"); + + // POST /swap-instructions + let swap_instructions = jupiter_client + .get_swap_instructions(quote_response, TEST_WALLET) + .await; + + assert!(swap_instructions.is_ok()); + } +} diff --git a/src/jupiter/serde_helpers/field_as_string.rs b/src/jupiter/serde_helpers/field_as_string.rs new file mode 100644 index 0000000..467b5d6 --- /dev/null +++ b/src/jupiter/serde_helpers/field_as_string.rs @@ -0,0 +1,24 @@ +use { + serde::{de, Deserializer, Serializer}, + serde::{Deserialize, Serialize}, + std::str::FromStr, +}; + +pub fn serialize(t: &T, serializer: S) -> Result +where + T: ToString, + S: Serializer, +{ + t.to_string().serialize(serializer) +} + +pub fn deserialize<'de, T, D>(deserializer: D) -> Result +where + T: FromStr, + D: Deserializer<'de>, + ::Err: std::fmt::Debug, +{ + let s: String = String::deserialize(deserializer)?; + s.parse() + .map_err(|e| de::Error::custom(format!("Parse error: {:?}", e))) +} diff --git a/src/jupiter/serde_helpers/mod.rs b/src/jupiter/serde_helpers/mod.rs new file mode 100644 index 0000000..78507d0 --- /dev/null +++ b/src/jupiter/serde_helpers/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod field_as_string; +pub(crate) mod option_field_as_string; diff --git a/src/jupiter/serde_helpers/option_field_as_string.rs b/src/jupiter/serde_helpers/option_field_as_string.rs new file mode 100644 index 0000000..55ae892 --- /dev/null +++ b/src/jupiter/serde_helpers/option_field_as_string.rs @@ -0,0 +1,13 @@ +use {serde::Serialize, serde::Serializer}; + +pub fn serialize(t: &Option, serializer: S) -> Result +where + T: ToString, + S: Serializer, +{ + if let Some(t) = t { + t.to_string().serialize(serializer) + } else { + serializer.serialize_none() + } +} diff --git a/src/jupiter/swap.rs b/src/jupiter/swap.rs new file mode 100644 index 0000000..9ef1b71 --- /dev/null +++ b/src/jupiter/swap.rs @@ -0,0 +1,134 @@ +use serde::{Deserialize, Serialize}; +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +use crate::jupiter::field_as_string; + +use super::{transaction_config::TransactionConfig, QuoteResponse}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapRequest { + #[serde(with = "field_as_string")] + pub user_public_key: Pubkey, + + pub quote_response: QuoteResponse, + + #[serde(flatten)] + pub config: TransactionConfig, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapResponse { + #[serde(with = "base64_deserialize")] + pub swap_transaction: Vec, + pub last_valid_block_height: u64, +} + +mod base64_deserialize { + use super::*; + use serde::{de, Deserializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let swap_transaction_string = String::deserialize(deserializer)?; + base64::decode(swap_transaction_string) + .map_err(|e| de::Error::custom(format!("base64 decoding error: {:?}", e))) + } +} + +#[derive(Debug)] +pub struct SwapInstructionsResponse { + pub token_ledger_instruction: Option, + pub compute_budget_instructions: Vec, + pub setup_instructions: Vec, + /// Instruction performing the action of swapping + pub swap_instruction: Instruction, + pub cleanup_instruction: Option, + pub address_lookup_table_addresses: Vec, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SwapInstructionsResponseInternal { + token_ledger_instruction: Option, + compute_budget_instructions: Vec, + setup_instructions: Vec, + /// Instruction performing the action of swapping + swap_instruction: InstructionInternal, + cleanup_instruction: Option, + address_lookup_table_addresses: Vec, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct InstructionInternal { + #[serde(with = "field_as_string")] + pub program_id: Pubkey, + pub accounts: Vec, + #[serde(with = "base64_deserialize")] + pub data: Vec, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AccountMetaInternal { + #[serde(with = "field_as_string")] + pub pubkey: Pubkey, + pub is_signer: bool, + pub is_writable: bool, +} + +impl From for AccountMeta { + fn from(val: AccountMetaInternal) -> Self { + Self { + pubkey: val.pubkey, + is_signer: val.is_signer, + is_writable: val.is_writable, + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct PubkeyInternal(#[serde(with = "field_as_string")] Pubkey); + +impl From for Instruction { + fn from(val: InstructionInternal) -> Self { + Self { + program_id: val.program_id, + accounts: val.accounts.into_iter().map(Into::into).collect(), + data: val.data, + } + } +} + +impl From for SwapInstructionsResponse { + fn from(value: SwapInstructionsResponseInternal) -> Self { + Self { + token_ledger_instruction: value.token_ledger_instruction.map(Into::into), + compute_budget_instructions: value + .compute_budget_instructions + .into_iter() + .map(Into::into) + .collect(), + setup_instructions: value + .setup_instructions + .into_iter() + .map(Into::into) + .collect(), + swap_instruction: value.swap_instruction.into(), + cleanup_instruction: value.cleanup_instruction.map(Into::into), + address_lookup_table_addresses: value + .address_lookup_table_addresses + .into_iter() + .map(|p| p.0) + .collect(), + } + } +} diff --git a/src/jupiter/transaction_config.rs b/src/jupiter/transaction_config.rs new file mode 100644 index 0000000..79fc179 --- /dev/null +++ b/src/jupiter/transaction_config.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use solana_sdk::pubkey::Pubkey; + +use crate::jupiter::serde_helpers::option_field_as_string; + +#[derive(Deserialize, Serialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +#[serde(untagged)] +pub enum ComputeUnitPriceMicroLamports { + MicroLamports(u64), + #[serde(deserialize_with = "auto")] + Auto, +} + +fn auto<'de, D>(deserializer: D) -> Result<(), D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + enum Helper { + #[serde(rename = "auto")] + Variant, + } + Helper::deserialize(deserializer)?; + Ok(()) +} + +#[derive(Serialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct TransactionConfig { + /// Wrap and unwrap SOL. Will be ignored if `destination_token_account` is set because the `destination_token_account` may belong to a different user that we have no authority to close. + pub wrap_and_unwrap_sol: bool, + + /// Fee token account for the output token, it is derived using the seeds = ["referral_ata", referral_account, mint] and the `REFER4ZgmyYx9c6He5XfaTMiGfdLwRnkV4RPp9t9iF3` referral contract (only pass in if you set a feeBps and make sure that the feeAccount has been created) + #[serde(with = "option_field_as_string")] + pub fee_account: Option, + + /// Public key of the token account that will be used to receive the token out of the swap. If not provided, the user's ATA will be used. If provided, we assume that the token account is already initialized. + #[serde(with = "option_field_as_string")] + pub destination_token_account: Option, + + /// compute unit price to prioritize the transaction, the additional fee will be compute unit consumed * computeUnitPriceMicroLamports + pub compute_unit_price_micro_lamports: Option, + + /// Request a legacy transaction rather than the default versioned transaction, needs to be paired with a quote using asLegacyTransaction otherwise the transaction might be too large + /// + /// Default: false + pub as_legacy_transaction: bool, + + /// This enables the usage of shared program accounts. That means no intermediate token accounts or open orders accounts need to be created. + /// But it also means that the likelihood of hot accounts is higher. + /// + /// Default: true + pub use_shared_accounts: bool, + + /// This is useful when the instruction before the swap has a transfer that increases the input token amount. + /// Then, the swap will just use the difference between the token ledger token amount and post token amount. + /// + /// Default: false + pub use_token_ledger: bool, +} + +impl Default for TransactionConfig { + fn default() -> Self { + Self { + wrap_and_unwrap_sol: true, + fee_account: None, + destination_token_account: None, + compute_unit_price_micro_lamports: None, + as_legacy_transaction: false, + use_shared_accounts: true, + use_token_ledger: false, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 485aa2b..c78233a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -95,6 +95,7 @@ pub mod usermap; pub mod user; pub mod dlob; +pub mod jupiter; use types::*;