diff --git a/Cargo.lock b/Cargo.lock index b0429c3a68..e28f139d0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10312,6 +10312,7 @@ dependencies = [ "configs", "const-hex", "futures", + "hmac 0.12.1", "humantime-serde", "observe", "rate-limit", @@ -10319,7 +10320,11 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2 0.10.9", + "solana-compute-budget-interface", "solana-sdk", + "spl-associated-token-account-interface", + "spl-token-2022-interface 2.1.0", "thiserror 1.0.69", "tokio", "toml", diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index 47525a97df..2f2ea9ba68 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -2,7 +2,7 @@ name = "solana-solvers" version = "0.1.0" edition = "2024" -description = "Solana solver engines for CoW Protocol (Jupiter dex-wrapper)" +description = "Solana solver engines for CoW Protocol (Jupiter and Bitget dex-wrappers)" license = "GPL-3.0-or-later" [lib] @@ -21,6 +21,7 @@ clap = { workspace = true, features = ["derive", "env"] } configs = { workspace = true } const-hex = { workspace = true } futures = { workspace = true } +hmac = { workspace = true } humantime-serde = { workspace = true } observe = { workspace = true } rate-limit = { workspace = true } @@ -28,7 +29,11 @@ reqwest = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_with = { workspace = true } +sha2 = { workspace = true } +solana-compute-budget-interface = { workspace = true } solana-sdk = { workspace = true } +spl-associated-token-account-interface = { workspace = true } +spl-token-2022-interface = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "signal", "time"] } toml = { workspace = true } diff --git a/crates/solana-solvers/config/example.bitget.toml b/crates/solana-solvers/config/example.bitget.toml new file mode 100644 index 0000000000..12f256439e --- /dev/null +++ b/crates/solana-solvers/config/example.bitget.toml @@ -0,0 +1,22 @@ +# Example Bitget solver configuration. +# Run with: solana-solvers bitget --config + +# Orders quoted at once. Each quote is one Bitget request. +concurrent-requests = 8 +# Pause after a rate-limited response: min-back-off at first, multiplied by +# back-off-growth-factor per consecutive one, capped at max-back-off. +back-off-growth-factor = 2.0 +min-back-off = "1s" +max-back-off = "8s" + +[dex] +# Bitget swap API base URL, or a proxy route to it. +endpoint = "https://bopenapi.bgwapi.io/bgw-pro/swapx/pro/" +# Credentials from the Bitget developer portal. Requests are signed with the secret. +api-key = "your-bitget-api-key" +api-secret = "your-bitget-api-secret" +# Slippage tolerance in basis points. Bids use Bitget's minimum output, so this +# is how far below its expected output they land. 20 = 0.2%. +slippage-bps = 20 +# Most accounts Bitget's route may use, leaving room for the settlement. +max-accounts = 48 diff --git a/crates/solana-solvers/src/cli.rs b/crates/solana-solvers/src/cli.rs index 9a8cadbe3e..05a4421b52 100644 --- a/crates/solana-solvers/src/cli.rs +++ b/crates/solana-solvers/src/cli.rs @@ -34,5 +34,10 @@ pub enum Command { #[clap(long, env)] config: PathBuf, }, + /// Wrap Bitget's swap API into single-order solutions. + Bitget { + #[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 index 5455fa0448..e16cb2dfc9 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -2,16 +2,16 @@ use { configs::rate_limit::Strategy, - serde::Deserialize, + serde::{Deserialize, de::DeserializeOwned}, std::{num::NonZeroUsize, path::Path, time::Duration}, url::Url, }; -/// Jupiter solver configuration. +/// Solver configuration around the engine's `[dex]` table. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct Config { - /// Orders quoted at once. Each quote is two sequential Jupiter requests. +pub struct Config { + /// Orders quoted at once. #[serde(default = "default_concurrent_requests")] pub concurrent_requests: NonZeroUsize, @@ -27,11 +27,11 @@ pub struct Config { #[serde(with = "humantime_serde", default = "default_max_back_off")] pub max_back_off: Duration, - pub dex: JupiterConfig, + pub dex: D, } -impl Config { - /// The back-off applied to rate-limited Jupiter responses. +impl Config { + /// The back-off applied to rate-limited DEX responses. /// /// # Panics /// @@ -85,12 +85,37 @@ pub struct JupiterConfig { pub enable_buy_orders: bool, } +/// The `[dex]` table for the Bitget backend. No `Debug`: it holds the secret. +#[derive(Clone, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct BitgetConfig { + /// Base URL of the Bitget swap API, or a proxy route to it. + pub endpoint: Url, + + pub api_key: String, + + pub api_secret: String, + + /// Slippage tolerance in basis points. We bid Bitget's minimum output, so + /// this is how far below its expected output the bids land. + pub slippage_bps: u16, + + /// Caps the accounts Bitget's route may use, leaving room for the + /// settlement around it in the 64 a transaction can lock. + #[serde(default = "default_max_accounts")] + pub max_accounts: u16, +} + +fn default_max_accounts() -> u16 { + 48 +} + /// 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 { +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())); @@ -103,7 +128,7 @@ mod tests { #[test] fn parses_example_config() { - let config: 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); @@ -123,7 +148,7 @@ mod tests { endpoint = "https://api.jup.ag" slippage-bps = 50 "#; - let config: Config = toml::from_str(toml).unwrap(); + let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.concurrent_requests.get(), 8); let strategy = config.rate_limiting(); assert_eq!(strategy.back_off_growth_factor, 2.0); @@ -139,6 +164,31 @@ endpoint = "https://api.jup.ag" slippage-bps = 50 bogus = true "#; - assert!(toml::from_str::(toml).is_err()); + assert!(toml::from_str::>(toml).is_err()); + } + + #[test] + fn parses_bitget_example_config() { + let config: Config = + toml::from_str(include_str!("../config/example.bitget.toml")).unwrap(); + assert_eq!( + config.dex.endpoint.as_str(), + "https://bopenapi.bgwapi.io/bgw-pro/swapx/pro/" + ); + assert_eq!(config.dex.slippage_bps, 20); + assert_eq!(config.dex.max_accounts, 48); + } + + #[test] + fn requires_bitget_credentials() { + // Deploy templating drops `key = ""` lines, so a missing secret must + // fail to parse. + let toml = r#" +[dex] +endpoint = "https://bopenapi.bgwapi.io/bgw-pro/swapx/pro/" +api-key = "key" +slippage-bps = 50 +"#; + assert!(toml::from_str::>(toml).is_err()); } } diff --git a/crates/solana-solvers/src/dex/bitget/dto.rs b/crates/solana-solvers/src/dex/bitget/dto.rs new file mode 100644 index 0000000000..edfe61c490 --- /dev/null +++ b/crates/solana-solvers/src/dex/bitget/dto.rs @@ -0,0 +1,240 @@ +//! DTOs for Bitget's `/swap` request and response. + +use { + crate::dex::{Error, Order, Swap, dto::ApiInstruction}, + serde::{Deserialize, Serialize}, + serde_with::{DisplayFromStr, serde_as}, + solana_sdk::pubkey::Pubkey, + spl_associated_token_account_interface::address::get_associated_token_address_with_program_id, + spl_token_2022_interface::inline_spl_token, +}; + +/// Bitget's aggregator. Naming it lets `/swap` skip the `/quote` call. +const MARKET: &str = "bgwaggregator"; +/// Bitget's smallest fee. Without a fee its route also needs Bitget's +/// co-signature, and the settlement has a single signer. +const FEE_RATE: f64 = 0.0001; + +/// Body of the `/swap` request for a sell order, amounts in atoms. +#[serde_as] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapRequest { + from_chain: &'static str, + #[serde_as(as = "DisplayFromStr")] + from_contract: Pubkey, + #[serde_as(as = "DisplayFromStr")] + from_amount: u64, + to_chain: &'static str, + #[serde_as(as = "DisplayFromStr")] + to_contract: Pubkey, + amount_unit: &'static str, + #[serde_as(as = "DisplayFromStr")] + from_address: Pubkey, + #[serde_as(as = "DisplayFromStr")] + to_address: Pubkey, + market: &'static str, + /// Percent. + slippage: f64, + fee_rate: f64, + request_mod: &'static str, + #[serde_as(as = "DisplayFromStr")] + sol_max_accounts: u16, +} + +impl SwapRequest { + pub fn new(order: &Order, taker: &Pubkey, slippage_bps: u16, max_accounts: u16) -> Self { + Self { + from_chain: "sol", + from_contract: order.sell_mint, + from_amount: order.sell_amount, + to_chain: "sol", + to_contract: order.buy_mint, + amount_unit: "wei", + from_address: *taker, + to_address: *taker, + market: MARKET, + slippage: f64::from(slippage_bps) / 100.0, + fee_rate: FEE_RATE, + request_mod: "rich", + sol_max_accounts: max_accounts, + } + } +} + +/// Bitget's response wrapper: `status` 0 carries `data`, any other an +/// [`error_code`](https://web3.bitget.com/en/docs/swap-order#error-code-list). +#[derive(Deserialize)] +pub struct Envelope { + status: i64, + error_code: Option, + msg: Option, + data: Option, +} + +impl Envelope { + pub fn into_data(self, http_status: u16) -> Result { + if self.status != 0 { + let code = self.error_code.unwrap_or_default(); + return Err(match code { + // No route, or a Bitget-side failure the next auction retries. + 80000 | 80001 | 80004 | 80005 | 80008..=80012 | 80014 | 80020 => Error::NotFound, + _ => Error::Api { + status: http_status, + body: format!("error_code {code}: {}", self.msg.unwrap_or_default()), + }, + }); + } + let data = self.data.ok_or(Error::NotFound)?; + serde_json::from_value(data).map_err(|err| Error::BadResponse(format!("swap data: {err}"))) + } +} + +/// The parts of a rich-mode `/swap` response we need. `minAmount` is the +/// output after Bitget's fee, which its program enforces on chain. +#[serde_as] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SwapResponse { + #[serde_as(as = "DisplayFromStr")] + min_amount: u64, + instruction_lists: Vec, + #[serde_as(as = "Vec")] + address_lookup_table_account: Vec, +} + +impl SwapResponse { + /// Bitget pays the taker's buy-mint ATA, so a transfer of `minAmount` moves + /// the output on to `order.buy_destination` and positive slippage stays + /// with the taker. ComputeBudget instructions are dropped: the driver sets + /// its own. + pub fn into_swap(self, order: &Order, taker: &Pubkey) -> Result { + let mut instructions = Vec::with_capacity(self.instruction_lists.len() + 1); + for instruction in self.instruction_lists { + let instruction = instruction.into_instruction()?; + if instruction.program_id != solana_compute_budget_interface::ID { + instructions.push(instruction); + } + } + let accounts = || { + instructions + .iter() + .flat_map(|instruction| &instruction.accounts) + }; + if accounts().any(|account| account.is_signer && account.pubkey != *taker) { + return Err(Error::BadResponse( + "route needs a signer besides the taker".into(), + )); + } + let (token_program, output) = [inline_spl_token::ID, spl_token_2022_interface::ID] + .into_iter() + .map(|program| { + let ata = + get_associated_token_address_with_program_id(taker, &order.buy_mint, &program); + (program, ata) + }) + .find(|(_, ata)| accounts().any(|account| account.pubkey == *ata)) + .ok_or_else(|| Error::BadResponse("route doesn't pay the taker's ATA".into()))?; + // Unchecked because the auction carries no decimals. Token-2022 mints + // with a transfer fee or hook reject it, and simulation drops them. + #[allow(deprecated)] + let transfer = spl_token_2022_interface::instruction::transfer( + &token_program, + &output, + &order.buy_destination, + taker, + &[], + self.min_amount, + ) + .expect("both token programs pass the program check"); + instructions.push(transfer); + Ok(Swap { + in_amount: order.sell_amount, + out_amount: self.min_amount, + instructions, + address_lookup_tables: self.address_lookup_table_account, + }) + } +} + +#[cfg(test)] +mod tests { + use {super::*, crate::dex::Side}; + + fn order() -> Order { + Order { + sell_mint: Pubkey::new_unique(), + buy_mint: Pubkey::new_unique(), + buy_destination: Pubkey::new_unique(), + sell_amount: 1_000, + buy_amount: 1, + side: Side::Sell, + } + } + + fn response( + order: &Order, + taker: &Pubkey, + token_program: &Pubkey, + extra_signer: Option, + ) -> SwapResponse { + let output = + get_associated_token_address_with_program_id(taker, &order.buy_mint, token_program); + let mut accounts = vec![ + serde_json::json!({"pubkey": taker.to_string(), "isSigner": true, "isWritable": true}), + serde_json::json!({"pubkey": output.to_string(), "isSigner": false, "isWritable": true}), + ]; + if let Some(signer) = extra_signer { + accounts.push(serde_json::json!({"pubkey": signer.to_string(), "isSigner": true, "isWritable": false})); + } + serde_json::from_value(serde_json::json!({ + "minAmount": "990", + "instructionLists": [ + {"programId": solana_compute_budget_interface::ID.to_string(), "accounts": [], "data": "AnBkCAA="}, + {"programId": Pubkey::new_unique().to_string(), "accounts": accounts, "data": "AQ=="}, + ], + "addressLookupTableAccount": [Pubkey::new_unique().to_string()], + })) + .unwrap() + } + + #[test] + fn moves_the_minimum_output_into_the_buffer() { + let taker = Pubkey::new_unique(); + let order = order(); + for program in [inline_spl_token::ID, spl_token_2022_interface::ID] { + let swap = response(&order, &taker, &program, None) + .into_swap(&order, &taker) + .unwrap(); + + assert_eq!((swap.in_amount, swap.out_amount), (1_000, 990)); + assert_eq!(swap.address_lookup_tables.len(), 1); + // ComputeBudget dropped: the route, then the transfer. + assert_eq!(swap.instructions.len(), 2); + let transfer = &swap.instructions[1]; + assert_eq!(transfer.program_id, program); + let paid = + get_associated_token_address_with_program_id(&taker, &order.buy_mint, &program); + assert_eq!(transfer.accounts[0].pubkey, paid); + assert_eq!(transfer.accounts[1].pubkey, order.buy_destination); + assert_eq!(transfer.accounts[2].pubkey, taker); + assert_eq!(transfer.data[1..], 990u64.to_le_bytes()); + } + } + + #[test] + fn rejects_a_route_needing_another_signer() { + let taker = Pubkey::new_unique(); + let order = order(); + let response = response( + &order, + &taker, + &inline_spl_token::ID, + Some(Pubkey::new_unique()), + ); + assert!(matches!( + response.into_swap(&order, &taker), + Err(Error::BadResponse(_)) + )); + } +} diff --git a/crates/solana-solvers/src/dex/bitget/mod.rs b/crates/solana-solvers/src/dex/bitget/mod.rs new file mode 100644 index 0000000000..32545d62c9 --- /dev/null +++ b/crates/solana-solvers/src/dex/bitget/mod.rs @@ -0,0 +1,161 @@ +//! Bitget swap-API adapter. +//! +//! One signed `/swap` call per sell order. Buys are unsupported: `/swap` only +//! quotes exact-in. + +mod dto; + +use { + super::{Error, Order, Side, Swap}, + crate::config::BitgetConfig, + base64::prelude::*, + hmac::{Hmac, Mac}, + sha2::Sha256, + solana_sdk::pubkey::Pubkey, + std::collections::BTreeMap, +}; + +const SWAP_PATH: &str = "swap"; +/// The path Bitget checks the signature against, whichever proxy `endpoint` +/// points at. +const SIGNED_SWAP_PATH: &str = "/bgw-pro/swapx/pro/swap"; +const PARTNER_CODE: &str = "cowswap"; + +/// Adapter over Bitget's swap API. +pub struct Bitget { + client: reqwest::Client, + endpoint: reqwest::Url, + api_key: String, + api_secret: String, + slippage_bps: u16, + max_accounts: u16, +} + +impl Bitget { + pub fn new(config: &BitgetConfig) -> Result { + let mut endpoint = config.endpoint.clone(); + // `join` would replace a last path segment that lacks the slash. + if !endpoint.path().ends_with('/') { + endpoint.set_path(&format!("{}/", endpoint.path())); + } + Ok(Self { + client: reqwest::Client::builder().build()?, + endpoint, + api_key: config.api_key.clone(), + api_secret: config.api_secret.clone(), + slippage_bps: config.slippage_bps, + max_accounts: config.max_accounts, + }) + } + + /// 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 { + if order.side == Side::Buy { + return Err(Error::OrderNotSupported); + } + let url = self + .endpoint + .join(SWAP_PATH) + .map_err(|_| Error::RequestBuildFailed)?; + let request = dto::SwapRequest::new(order, taker, self.slippage_bps, self.max_accounts); + let body = serde_json::to_string(&request).map_err(|_| Error::RequestBuildFailed)?; + let timestamp = chrono::Utc::now().timestamp_millis().to_string(); + let response = self + .client + .post(url) + .header("content-type", "application/json") + .header("Partner-Code", PARTNER_CODE) + .header("x-api-key", &self.api_key) + .header("x-api-timestamp", ×tamp) + .header("x-api-signature", self.signature(&body, ×tamp)) + .body(body) + .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 Err(Error::Api { + status: status.as_u16(), + body, + }); + } + serde_json::from_str::(&body) + .map_err(|err| Error::BadResponse(format!("response body: {err}")))? + .into_data(status.as_u16())? + .into_swap(order, taker) + } + + /// Base64 HMAC-SHA256, keyed by the secret, over the signed fields as JSON + /// with sorted keys. + fn signature(&self, body: &str, timestamp: &str) -> String { + let content = serde_json::to_string(&BTreeMap::from([ + ("apiPath", SIGNED_SWAP_PATH), + ("body", body), + ("x-api-key", self.api_key.as_str()), + ("x-api-timestamp", timestamp), + ])) + .expect("a string map serializes"); + let mut mac = Hmac::::new_from_slice(self.api_secret.as_bytes()) + .expect("HMAC takes keys of any length"); + mac.update(content.as_bytes()); + BASE64_STANDARD.encode(mac.finalize().into_bytes()) + } +} + +#[cfg(test)] +mod tests { + use {super::*, std::str::FromStr}; + + fn config(api_key: String, api_secret: String) -> BitgetConfig { + BitgetConfig { + endpoint: "https://bopenapi.bgwapi.io/bgw-pro/swapx/pro/" + .parse() + .unwrap(), + api_key, + api_secret, + slippage_bps: 20, + max_accounts: 48, + } + } + + /// Live Bitget API. Needs `BITGET_API_KEY` and `BITGET_API_SECRET`. + #[tokio::test] + #[ignore] + async fn bitget_live_sell() { + let bitget = Bitget::new(&config( + std::env::var("BITGET_API_KEY").unwrap(), + std::env::var("BITGET_API_SECRET").unwrap(), + )) + .unwrap(); + // 1 USDC for wSOL, paid into the settlement's wSOL buffer. + let order = Order { + sell_mint: Pubkey::from_str("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap(), + buy_mint: Pubkey::from_str("So11111111111111111111111111111111111111112").unwrap(), + buy_destination: Pubkey::from_str("4BAJ8YzUCw2H7CXgmCZdMG2jFLaFdGjREhxRqXma73Cv") + .unwrap(), + sell_amount: 1_000_000, + buy_amount: 0, + side: Side::Sell, + }; + let taker = Pubkey::new_unique(); + let swap = bitget.swap(&order, &taker).await.unwrap(); + + assert_eq!(swap.in_amount, 1_000_000); + assert!(swap.out_amount > 0); + let transfer = swap.instructions.last().unwrap(); + assert_eq!(transfer.accounts[1].pubkey, order.buy_destination); + + let solution = crate::dto::solution::Solution::new( + 0, + crate::dto::order::OrderUid([1; 32]), + &order, + swap, + ) + .unwrap(); + serde_json::to_string(&solution).unwrap(); + } +} diff --git a/crates/solana-solvers/src/dex/dto.rs b/crates/solana-solvers/src/dex/dto.rs new file mode 100644 index 0000000000..eb1f4ade30 --- /dev/null +++ b/crates/solana-solvers/src/dex/dto.rs @@ -0,0 +1,60 @@ +//! Instruction JSON shared by the aggregator APIs: `programId`, `accounts` and +//! base64 `data`. + +use { + super::Error, + base64::prelude::*, + serde::Deserialize, + solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + }, + std::str::FromStr, +}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiInstruction { + program_id: String, + accounts: Vec, + data: String, +} + +impl ApiInstruction { + pub 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(ApiAccount::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 ApiAccount { + pubkey: String, + is_signer: bool, + is_writable: bool, +} + +impl ApiAccount { + 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/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index 982b8a3d19..e064898ec5 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -3,14 +3,10 @@ use { super::Error, - crate::dex::Swap, - base64::prelude::*, + crate::dex::{Swap, dto::ApiInstruction}, serde::{Deserialize, Serialize}, serde_with::serde_as, - solana_sdk::{ - instruction::{AccountMeta, Instruction}, - pubkey::Pubkey, - }, + solana_sdk::pubkey::Pubkey, std::str::FromStr, }; @@ -64,10 +60,10 @@ impl<'a> SwapInstructionsRequest<'a> { #[serde(rename_all = "camelCase")] pub struct SwapInstructionsResponse { #[serde(default)] - setup_instructions: Vec, - swap_instruction: JupInstruction, + setup_instructions: Vec, + swap_instruction: ApiInstruction, #[serde(default)] - cleanup_instruction: Option, + cleanup_instruction: Option, #[serde(default)] address_lookup_table_addresses: Vec, } @@ -100,50 +96,3 @@ impl SwapInstructionsResponse { }) } } - -#[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 index 0ce1634cbc..63a594a0f9 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -6,7 +6,7 @@ mod dto; use { - super::{Order, Side, Swap}, + super::{Error, Order, Side, Swap}, crate::config::JupiterConfig, solana_sdk::pubkey::Pubkey, }; @@ -145,24 +145,6 @@ fn amount_field(quote: &serde_json::Value, field: &str) -> Result { .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}; diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index c0ab8ac2ca..ff0a5a9cfb 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -2,6 +2,8 @@ //! //! `Dex` dispatches to the configured engine. +pub mod bitget; +mod dto; pub mod jupiter; use solana_sdk::{instruction::Instruction, pubkey::Pubkey}; @@ -12,8 +14,8 @@ 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. + /// resolved upstream (driver or autopilot). `FinalizeSettle` then pushes + /// to the user. pub buy_destination: Pubkey, pub sell_amount: u64, pub buy_amount: u64, @@ -72,22 +74,41 @@ impl Swap { /// The configured DEX backend. pub enum Dex { Jupiter(jupiter::Jupiter), + Bitget(bitget::Bitget), } impl Dex { /// Build the swap for `order` that the settlement signer `user` executes. /// - /// The route spends its input from the sell-mint ATA of the solver. - /// Jupiter has no source-account override. The sell funds must already be - /// in that ATA. The caller must make sure that the ATA exists before the - /// pull instructions of the swap run. - pub async fn swap(&self, order: &Order, user: &Pubkey) -> Result { + /// The route spends its input from the sell-mint ATA of the solver. The + /// sell funds must already be in that ATA. The caller must make sure that + /// the ATA exists before the pull instructions of the swap run. + pub async fn swap(&self, order: &Order, user: &Pubkey) -> Result { match self { Dex::Jupiter(jupiter) => jupiter.swap(order, user).await, + Dex::Bitget(bitget) => bitget.swap(order, user).await, } } } +#[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("dex 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::*; diff --git a/crates/solana-solvers/src/domain/solver/mod.rs b/crates/solana-solvers/src/domain/solver/mod.rs index d718d6c113..6067e35094 100644 --- a/crates/solana-solvers/src/domain/solver/mod.rs +++ b/crates/solana-solvers/src/domain/solver/mod.rs @@ -20,7 +20,7 @@ pub trait Quote { &self, order: &dex::Order, taker: &Pubkey, - ) -> impl Future> + Send; + ) -> impl Future> + Send; } impl Quote for Dex { @@ -28,7 +28,7 @@ impl Quote for Dex { &self, order: &dex::Order, taker: &Pubkey, - ) -> impl Future> + Send { + ) -> impl Future> + Send { self.swap(order, taker) } } @@ -101,16 +101,16 @@ impl Solver { let swap = self .rate_limiter .execute_with_back_off(self.quoter.quote(&dex_order, &auction.taker), |result| { - matches!(result, Err(dex::jupiter::Error::RateLimited)) + matches!(result, Err(dex::Error::RateLimited)) }) .await // A quote dropped during a back-off counts as rate limited. - .unwrap_or(Err(dex::jupiter::Error::RateLimited)) + .unwrap_or(Err(dex::Error::RateLimited)) .inspect_err(|err| match err { - dex::jupiter::Error::NotFound | dex::jupiter::Error::OrderNotSupported => { + dex::Error::NotFound | dex::Error::OrderNotSupported => { tracing::debug!("no solution for swap") } - dex::jupiter::Error::RateLimited => tracing::debug!("rate limited"), + dex::Error::RateLimited => tracing::debug!("rate limited"), _ => tracing::warn!(%err, "quote failed"), }) .ok()?; @@ -211,7 +211,7 @@ mod tests { &self, order: &dex::Order, _taker: &Pubkey, - ) -> impl Future> + Send { + ) -> impl Future> + Send { let (side, sell_mint) = (order.side, order.sell_mint); // Nothing runs before the first poll: a quote still held back by // the back-off is not recorded. @@ -220,12 +220,10 @@ mod tests { let running = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; self.max_in_flight.fetch_max(running, Ordering::SeqCst); let result = match side { - dex::Side::Buy => Err(dex::jupiter::Error::OrderNotSupported), - dex::Side::Sell if sell_mint == pubkey(NO_ROUTE) => { - Err(dex::jupiter::Error::NotFound) - } + dex::Side::Buy => Err(dex::Error::OrderNotSupported), + dex::Side::Sell if sell_mint == pubkey(NO_ROUTE) => Err(dex::Error::NotFound), dex::Side::Sell if sell_mint == pubkey(RATE_LIMITED) => { - Err(dex::jupiter::Error::RateLimited) + Err(dex::Error::RateLimited) } dex::Side::Sell => { let delay = if sell_mint == pubkey(SLOW) { diff --git a/crates/solana-solvers/src/lib.rs b/crates/solana-solvers/src/lib.rs index b821b14876..91d99161e0 100644 --- a/crates/solana-solvers/src/lib.rs +++ b/crates/solana-solvers/src/lib.rs @@ -1,7 +1,8 @@ //! 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. +//! Dex-wrappers over Jupiter and Bitget, mirroring the `crates/solvers` shape +//! over Solana-native types (`u64`, `Pubkey`, instructions). Hosts the `/solve` +//! API. pub mod api; mod cli; diff --git a/crates/solana-solvers/src/run.rs b/crates/solana-solvers/src/run.rs index 0bbdfa1613..b00e5433c8 100644 --- a/crates/solana-solvers/src/run.rs +++ b/crates/solana-solvers/src/run.rs @@ -26,30 +26,40 @@ pub async fn start(args: impl IntoIterator) { observe::tracing::init::initialize_reentrant(&obs_config); tracing::info!(version = %observe::version::git_version(), "running solana-solvers with {args:#?}"); - match args.command { + let solver = match args.command { Command::Jupiter { config: path } => { - let config = config::load(&path).await; + let config = config::load::(&path).await; let jupiter = dex::jupiter::Jupiter::new(&config.dex) .unwrap_or_else(|err| panic!("build jupiter dex: {err}")); - let solver = Solver::new( + Solver::new( dex::Dex::Jupiter(jupiter), config.concurrent_requests, config.rate_limiting(), - ); - let api = Api { - addr: args.addr, - solver: Arc::new(solver), - }; - let (listener, _addr) = api - .bind() - .await - .unwrap_or_else(|err| panic!("bind solana-solvers: {err}")); - if let Err(err) = api - .serve(listener, observe::shutdown::shutdown_signal()) - .await - { - tracing::error!(?err, "server error"); - } + ) } + Command::Bitget { config: path } => { + let config = config::load::(&path).await; + let bitget = dex::bitget::Bitget::new(&config.dex) + .unwrap_or_else(|err| panic!("build bitget dex: {err}")); + Solver::new( + dex::Dex::Bitget(bitget), + config.concurrent_requests, + config.rate_limiting(), + ) + } + }; + let api = Api { + addr: args.addr, + solver: Arc::new(solver), + }; + let (listener, _addr) = api + .bind() + .await + .unwrap_or_else(|err| panic!("bind solana-solvers: {err}")); + if let Err(err) = api + .serve(listener, observe::shutdown::shutdown_signal()) + .await + { + tracing::error!(?err, "server error"); } }