From e6cbb3ca51ddd8ba601123b25698a3ff650cb57a Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Tue, 13 Oct 2026 18:39:46 +0100 Subject: [PATCH 1/7] write stub code for solana bitget solver Signed-off-by: Aryan Godara --- .../solana-solvers/config/example.bitget.toml | 19 ++++++ crates/solana-solvers/src/cli.rs | 5 ++ crates/solana-solvers/src/config.rs | 62 +++++++++++++++---- crates/solana-solvers/src/dex/bitget.rs | 15 +++++ crates/solana-solvers/src/dex/jupiter/mod.rs | 20 +----- crates/solana-solvers/src/dex/mod.rs | 23 ++++++- .../solana-solvers/src/domain/solver/mod.rs | 22 +++---- crates/solana-solvers/src/run.rs | 44 +++++++------ 8 files changed, 149 insertions(+), 61 deletions(-) create mode 100644 crates/solana-solvers/config/example.bitget.toml create mode 100644 crates/solana-solvers/src/dex/bitget.rs diff --git a/crates/solana-solvers/config/example.bitget.toml b/crates/solana-solvers/config/example.bitget.toml new file mode 100644 index 0000000000..4c9d1d4260 --- /dev/null +++ b/crates/solana-solvers/config/example.bitget.toml @@ -0,0 +1,19 @@ +# Example Bitget solver configuration. +# Run with: solana-solvers bitget --config + +# Orders quoted at once. Each quote is two sequential Bitget requests. +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. Keep the trailing slash. +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. 50 = 0.5%. +slippage-bps = 50 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..cc253b898f 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. Each quote is two sequential DEX requests. #[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,29 @@ 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. `quote` and `swap` are joined onto it, + /// so it needs the trailing slash. + pub endpoint: Url, + + pub api_key: String, + + /// Signs every request. + pub api_secret: String, + + /// Slippage tolerance in basis points. 50 = 0.5%. + pub slippage_bps: u16, +} + /// Load and parse the TOML config file. /// /// # Panics /// /// Panics on I/O or parse errors: a bad config is a startup failure. -pub async fn load(path: &Path) -> Config { +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 +120,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 +140,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 +156,29 @@ 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, 50); + } + + #[test] + fn requires_bitget_credentials() { + // Deployments drop a line whose secret is unset. + 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.rs b/crates/solana-solvers/src/dex/bitget.rs new file mode 100644 index 0000000000..34a5f2022b --- /dev/null +++ b/crates/solana-solvers/src/dex/bitget.rs @@ -0,0 +1,15 @@ +//! Bitget swap-API adapter. + +use { + super::{Error, Order, Swap}, + solana_sdk::pubkey::Pubkey, +}; + +pub struct Bitget; + +impl Bitget { + // TODO(BE-253): quote the order and build the swap. + pub async fn swap(&self, _order: &Order, _taker: &Pubkey) -> Result { + Err(Error::NotFound) + } +} 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..8559135999 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -2,6 +2,7 @@ //! //! `Dex` dispatches to the configured engine. +pub mod bitget; pub mod jupiter; use solana_sdk::{instruction::Instruction, pubkey::Pubkey}; @@ -72,6 +73,7 @@ impl Swap { /// The configured DEX backend. pub enum Dex { Jupiter(jupiter::Jupiter), + Bitget(bitget::Bitget), } impl Dex { @@ -81,13 +83,32 @@ impl Dex { /// 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 { + 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/run.rs b/crates/solana-solvers/src/run.rs index 0bbdfa1613..226d34ef8e 100644 --- a/crates/solana-solvers/src/run.rs +++ b/crates/solana-solvers/src/run.rs @@ -26,30 +26,38 @@ 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; + Solver::new( + dex::Dex::Bitget(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"); } } From 93fdc015b7ecf87937f6b8bfad6de7d9c772d264 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Tue, 13 Oct 2026 19:20:54 +0100 Subject: [PATCH 2/7] implement bitget /swap adapter for solana solver Signed-off-by: Aryan Godara --- Cargo.lock | 5 + crates/solana-solvers/Cargo.toml | 7 +- .../solana-solvers/config/example.bitget.toml | 9 +- crates/solana-solvers/src/config.rs | 17 +- crates/solana-solvers/src/dex/bitget.rs | 15 -- crates/solana-solvers/src/dex/bitget/dto.rs | 235 ++++++++++++++++++ crates/solana-solvers/src/dex/bitget/mod.rs | 167 +++++++++++++ crates/solana-solvers/src/dex/dto.rs | 60 +++++ crates/solana-solvers/src/dex/jupiter/dto.rs | 61 +---- crates/solana-solvers/src/dex/mod.rs | 1 + crates/solana-solvers/src/lib.rs | 5 +- crates/solana-solvers/src/run.rs | 4 +- 12 files changed, 505 insertions(+), 81 deletions(-) delete mode 100644 crates/solana-solvers/src/dex/bitget.rs create mode 100644 crates/solana-solvers/src/dex/bitget/dto.rs create mode 100644 crates/solana-solvers/src/dex/bitget/mod.rs create mode 100644 crates/solana-solvers/src/dex/dto.rs diff --git a/Cargo.lock b/Cargo.lock index 731ceb9ae6..054bc651a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10283,6 +10283,7 @@ dependencies = [ "configs", "const-hex", "futures", + "hmac 0.12.1", "humantime-serde", "observe", "rate-limit", @@ -10290,7 +10291,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 index 4c9d1d4260..925474382f 100644 --- a/crates/solana-solvers/config/example.bitget.toml +++ b/crates/solana-solvers/config/example.bitget.toml @@ -1,7 +1,7 @@ # Example Bitget solver configuration. # Run with: solana-solvers bitget --config -# Orders quoted at once. Each quote is two sequential Bitget requests. +# 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. @@ -15,5 +15,8 @@ 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. 50 = 0.5%. -slippage-bps = 50 +# 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/config.rs b/crates/solana-solvers/src/config.rs index cc253b898f..c288cb1b04 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -11,7 +11,7 @@ use { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Config { - /// Orders quoted at once. Each quote is two sequential DEX requests. + /// Orders quoted at once. #[serde(default = "default_concurrent_requests")] pub concurrent_requests: NonZeroUsize, @@ -98,8 +98,18 @@ pub struct BitgetConfig { /// Signs every request. pub api_secret: String, - /// Slippage tolerance in basis points. 50 = 0.5%. + /// 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. @@ -167,7 +177,8 @@ bogus = true config.dex.endpoint.as_str(), "https://bopenapi.bgwapi.io/bgw-pro/swapx/pro/" ); - assert_eq!(config.dex.slippage_bps, 50); + assert_eq!(config.dex.slippage_bps, 20); + assert_eq!(config.dex.max_accounts, 48); } #[test] diff --git a/crates/solana-solvers/src/dex/bitget.rs b/crates/solana-solvers/src/dex/bitget.rs deleted file mode 100644 index 34a5f2022b..0000000000 --- a/crates/solana-solvers/src/dex/bitget.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Bitget swap-API adapter. - -use { - super::{Error, Order, Swap}, - solana_sdk::pubkey::Pubkey, -}; - -pub struct Bitget; - -impl Bitget { - // TODO(BE-253): quote the order and build the swap. - pub async fn swap(&self, _order: &Order, _taker: &Pubkey) -> Result { - Err(Error::NotFound) - } -} 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..36f8813538 --- /dev/null +++ b/crates/solana-solvers/src/dex/bitget/dto.rs @@ -0,0 +1,235 @@ +//! 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`. +#[derive(Deserialize)] +pub struct Envelope { + status: i64, + #[serde(default)] + error_code: Option, + #[serde(default)] + msg: Option, + #[serde(default)] + data: Option, +} + +impl Envelope { + pub fn into_data(self, http_status: u16) -> Result { + if self.status != 0 { + return Err(match self.error_code { + // No route, or a Bitget-side failure the next auction retries. + Some(80000 | 80001 | 80004 | 80005 | 80008..=80012 | 80014 | 80020) => { + Error::NotFound + } + code => Error::Api { + status: http_status, + body: format!("error {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()))?; + if output != order.buy_destination { + // 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, extra_signer: Option) -> SwapResponse { + let output = get_associated_token_address_with_program_id( + taker, + &order.buy_mint, + &inline_spl_token::ID, + ); + 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(); + let swap = response(&order, &taker, 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, inline_spl_token::ID); + 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, 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..b83a68c9f3 --- /dev/null +++ b/crates/solana-solvers/src/dex/bitget/mod.rs @@ -0,0 +1,167 @@ +//! 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 { + Ok(Self { + client: reqwest::Client::builder().build()?, + endpoint: config.endpoint.clone(), + 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::*, crate::dex::Side, 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, + } + } + + /// Computed independently with Python's `hmac`, using the same signer that + /// the live API accepted. + #[test] + fn signs_like_bitget_expects() { + let bitget = Bitget::new(&config("test-key".into(), "test-secret".into())).unwrap(); + assert_eq!( + bitget.signature(r#"{"fromChain":"sol"}"#, "1700000000000"), + "QcupArhjrn7Fkp1hVL4Pq1ar2oHyzVDXrgQ3XUng0W0=" + ); + } + + /// 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/mod.rs b/crates/solana-solvers/src/dex/mod.rs index 8559135999..c8766d6c68 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -3,6 +3,7 @@ //! `Dex` dispatches to the configured engine. pub mod bitget; +mod dto; pub mod jupiter; use solana_sdk::{instruction::Instruction, pubkey::Pubkey}; 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 226d34ef8e..b00e5433c8 100644 --- a/crates/solana-solvers/src/run.rs +++ b/crates/solana-solvers/src/run.rs @@ -39,8 +39,10 @@ pub async fn start(args: impl IntoIterator) { } 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(dex::bitget::Bitget), + dex::Dex::Bitget(bitget), config.concurrent_requests, config.rate_limiting(), ) From 7e4ddf5b643a1c575bcae0c0a86b1bbef372597e Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 16 Oct 2026 17:08:51 +0100 Subject: [PATCH 3/7] minor comment fixes Signed-off-by: Aryan Godara --- crates/solana-solvers/config/example.bitget.toml | 2 +- crates/solana-solvers/src/config.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/solana-solvers/config/example.bitget.toml b/crates/solana-solvers/config/example.bitget.toml index 4c9d1d4260..9b9e45a333 100644 --- a/crates/solana-solvers/config/example.bitget.toml +++ b/crates/solana-solvers/config/example.bitget.toml @@ -1,7 +1,7 @@ # Example Bitget solver configuration. # Run with: solana-solvers bitget --config -# Orders quoted at once. Each quote is two sequential Bitget requests. +# 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. diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index cc253b898f..1496a46bee 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -11,7 +11,7 @@ use { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Config { - /// Orders quoted at once. Each quote is two sequential DEX requests. + /// Orders quoted at once. #[serde(default = "default_concurrent_requests")] pub concurrent_requests: NonZeroUsize, @@ -89,13 +89,12 @@ pub struct JupiterConfig { #[derive(Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct BitgetConfig { - /// Base URL of the Bitget swap API. `quote` and `swap` are joined onto it, - /// so it needs the trailing slash. + /// Base URL of the Bitget swap API. `swap` is joined onto it, so it needs + /// the trailing slash. pub endpoint: Url, pub api_key: String, - /// Signs every request. pub api_secret: String, /// Slippage tolerance in basis points. 50 = 0.5%. @@ -172,7 +171,8 @@ bogus = true #[test] fn requires_bitget_credentials() { - // Deployments drop a line whose secret is unset. + // 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/" From 7a49dadba2bf6b0f0015ece0155a7a39d4e2fb3c Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 16 Oct 2026 17:33:00 +0100 Subject: [PATCH 4/7] address review: tolerate missing endpoint slash, tighten bitget swap test, trim docs Signed-off-by: Aryan Godara --- .../solana-solvers/config/example.bitget.toml | 2 +- crates/solana-solvers/src/config.rs | 3 +- crates/solana-solvers/src/dex/bitget/dto.rs | 95 ++++++++++--------- crates/solana-solvers/src/dex/bitget/mod.rs | 20 ++-- crates/solana-solvers/src/dex/mod.rs | 11 +-- 5 files changed, 64 insertions(+), 67 deletions(-) diff --git a/crates/solana-solvers/config/example.bitget.toml b/crates/solana-solvers/config/example.bitget.toml index 925474382f..12f256439e 100644 --- a/crates/solana-solvers/config/example.bitget.toml +++ b/crates/solana-solvers/config/example.bitget.toml @@ -10,7 +10,7 @@ min-back-off = "1s" max-back-off = "8s" [dex] -# Bitget swap API base URL, or a proxy route to it. Keep the trailing slash. +# 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" diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index 6eef508e2b..e16cb2dfc9 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -89,8 +89,7 @@ pub struct JupiterConfig { #[derive(Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct BitgetConfig { - /// Base URL of the Bitget swap API. `swap` is joined onto it, so it needs - /// the trailing slash. + /// Base URL of the Bitget swap API, or a proxy route to it. pub endpoint: Url, pub api_key: String, diff --git a/crates/solana-solvers/src/dex/bitget/dto.rs b/crates/solana-solvers/src/dex/bitget/dto.rs index 36f8813538..edfe61c490 100644 --- a/crates/solana-solvers/src/dex/bitget/dto.rs +++ b/crates/solana-solvers/src/dex/bitget/dto.rs @@ -63,29 +63,25 @@ impl SwapRequest { } /// Bitget's response wrapper: `status` 0 carries `data`, any other an -/// `error_code`. +/// [`error_code`](https://web3.bitget.com/en/docs/swap-order#error-code-list). #[derive(Deserialize)] pub struct Envelope { status: i64, - #[serde(default)] error_code: Option, - #[serde(default)] msg: Option, - #[serde(default)] data: Option, } impl Envelope { pub fn into_data(self, http_status: u16) -> Result { if self.status != 0 { - return Err(match self.error_code { + let code = self.error_code.unwrap_or_default(); + return Err(match code { // No route, or a Bitget-side failure the next auction retries. - Some(80000 | 80001 | 80004 | 80005 | 80008..=80012 | 80014 | 80020) => { - Error::NotFound - } - code => Error::Api { + 80000 | 80001 | 80004 | 80005 | 80008..=80012 | 80014 | 80020 => Error::NotFound, + _ => Error::Api { status: http_status, - body: format!("error {code:?}: {}", self.msg.unwrap_or_default()), + body: format!("error_code {code}: {}", self.msg.unwrap_or_default()), }, }); } @@ -139,22 +135,19 @@ impl SwapResponse { }) .find(|(_, ata)| accounts().any(|account| account.pubkey == *ata)) .ok_or_else(|| Error::BadResponse("route doesn't pay the taker's ATA".into()))?; - if output != order.buy_destination { - // 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); - } + // 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, @@ -179,12 +172,14 @@ mod tests { } } - fn response(order: &Order, taker: &Pubkey, extra_signer: Option) -> SwapResponse { - let output = get_associated_token_address_with_program_id( - taker, - &order.buy_mint, - &inline_spl_token::ID, - ); + 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}), @@ -207,26 +202,36 @@ mod tests { fn moves_the_minimum_output_into_the_buffer() { let taker = Pubkey::new_unique(); let order = order(); - let swap = response(&order, &taker, None) - .into_swap(&order, &taker) - .unwrap(); + 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, inline_spl_token::ID); - 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()); + 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, Some(Pubkey::new_unique())); + 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 index b83a68c9f3..32545d62c9 100644 --- a/crates/solana-solvers/src/dex/bitget/mod.rs +++ b/crates/solana-solvers/src/dex/bitget/mod.rs @@ -33,9 +33,14 @@ pub struct Bitget { 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: config.endpoint.clone(), + endpoint, api_key: config.api_key.clone(), api_secret: config.api_secret.clone(), slippage_bps: config.slippage_bps, @@ -103,7 +108,7 @@ impl Bitget { #[cfg(test)] mod tests { - use {super::*, crate::dex::Side, std::str::FromStr}; + use {super::*, std::str::FromStr}; fn config(api_key: String, api_secret: String) -> BitgetConfig { BitgetConfig { @@ -117,17 +122,6 @@ mod tests { } } - /// Computed independently with Python's `hmac`, using the same signer that - /// the live API accepted. - #[test] - fn signs_like_bitget_expects() { - let bitget = Bitget::new(&config("test-key".into(), "test-secret".into())).unwrap(); - assert_eq!( - bitget.signature(r#"{"fromChain":"sol"}"#, "1700000000000"), - "QcupArhjrn7Fkp1hVL4Pq1ar2oHyzVDXrgQ3XUng0W0=" - ); - } - /// Live Bitget API. Needs `BITGET_API_KEY` and `BITGET_API_SECRET`. #[tokio::test] #[ignore] diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index c8766d6c68..ff0a5a9cfb 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -14,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, @@ -80,10 +80,9 @@ pub enum Dex { 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. + /// 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, From f4a57ddbbdf12888198049ae23738d8f85113b23 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Thu, 22 Oct 2026 16:44:04 +0100 Subject: [PATCH 5/7] address review comments Signed-off-by: Aryan Godara --- Cargo.lock | 1 + crates/solana-solvers/Cargo.toml | 1 + crates/solana-solvers/src/config.rs | 1 + crates/solana-solvers/src/dex/bitget/dto.rs | 5 +++-- crates/solana-solvers/src/dex/bitget/mod.rs | 7 +------ 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e28f139d0b..b9c14e6d2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10318,6 +10318,7 @@ dependencies = [ "rate-limit", "reqwest 0.13.4", "serde", + "serde-ext", "serde_json", "serde_with", "sha2 0.10.9", diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index 2f2ea9ba68..19c25288e0 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -27,6 +27,7 @@ observe = { workspace = true } rate-limit = { workspace = true } reqwest = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde-ext = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } sha2 = { workspace = true } diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index e16cb2dfc9..c7eddf379b 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -90,6 +90,7 @@ pub struct JupiterConfig { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct BitgetConfig { /// Base URL of the Bitget swap API, or a proxy route to it. + #[serde(deserialize_with = "serde_ext::deserialize_url_with_trailing_slash")] pub endpoint: Url, pub api_key: String, diff --git a/crates/solana-solvers/src/dex/bitget/dto.rs b/crates/solana-solvers/src/dex/bitget/dto.rs index edfe61c490..ed2a45b2e9 100644 --- a/crates/solana-solvers/src/dex/bitget/dto.rs +++ b/crates/solana-solvers/src/dex/bitget/dto.rs @@ -90,8 +90,8 @@ impl Envelope { } } -/// The parts of a rich-mode `/swap` response we need. `minAmount` is the -/// output after Bitget's fee, which its program enforces on chain. +/// The parts of a rich-mode `/swap` response we need. Bitget's program checks +/// `minAmount` against what the receiver gets after its fee. #[serde_as] #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -99,6 +99,7 @@ pub struct SwapResponse { #[serde_as(as = "DisplayFromStr")] min_amount: u64, instruction_lists: Vec, + #[serde(default)] #[serde_as(as = "Vec")] address_lookup_table_account: Vec, } diff --git a/crates/solana-solvers/src/dex/bitget/mod.rs b/crates/solana-solvers/src/dex/bitget/mod.rs index 32545d62c9..7529ec2a66 100644 --- a/crates/solana-solvers/src/dex/bitget/mod.rs +++ b/crates/solana-solvers/src/dex/bitget/mod.rs @@ -33,14 +33,9 @@ pub struct Bitget { 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, + endpoint: config.endpoint.clone(), api_key: config.api_key.clone(), api_secret: config.api_secret.clone(), slippage_bps: config.slippage_bps, From 194762c8f824ed73c48c33bab7bb681dd39a5495 Mon Sep 17 00:00:00 2001 From: squadgazzz <22964585+squadgazzz@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:12:31 +0000 Subject: [PATCH 6/7] Read the Bitget partner code from config, carry its error codes as Error::Dex Mirrors the EVM Bitget config, which defaults partner-code to "cowswap". Envelope errors surfaced as Error::Api { status: 200 } before. Bitget's error_code does not fit a u16 HTTP status, so it gets its own variant. --- crates/solana-solvers/config/example.bitget.toml | 2 ++ crates/solana-solvers/src/config.rs | 9 +++++++++ crates/solana-solvers/src/dex/bitget/dto.rs | 8 ++++---- crates/solana-solvers/src/dex/bitget/mod.rs | 8 +++++--- crates/solana-solvers/src/dex/mod.rs | 2 ++ 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/solana-solvers/config/example.bitget.toml b/crates/solana-solvers/config/example.bitget.toml index 12f256439e..03cd94fd78 100644 --- a/crates/solana-solvers/config/example.bitget.toml +++ b/crates/solana-solvers/config/example.bitget.toml @@ -15,6 +15,8 @@ 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" +# Partner code sent in the Partner-Code header (defaults to "cowswap"). +# partner-code = "cowswap" # 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 diff --git a/crates/solana-solvers/src/config.rs b/crates/solana-solvers/src/config.rs index 0f0aa86deb..3c4cd3e98f 100644 --- a/crates/solana-solvers/src/config.rs +++ b/crates/solana-solvers/src/config.rs @@ -107,6 +107,10 @@ pub struct BitgetConfig { pub api_secret: String, + /// Partner code sent in the `Partner-Code` header. + #[serde(default = "default_partner_code")] + pub partner_code: 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, @@ -121,6 +125,10 @@ fn default_max_accounts() -> u16 { 48 } +fn default_partner_code() -> String { + "cowswap".to_string() +} + /// Load and parse the TOML config file. /// /// # Panics @@ -190,6 +198,7 @@ bogus = true ); assert_eq!(config.dex.slippage_bps, 20); assert_eq!(config.dex.max_accounts, 48); + assert_eq!(config.dex.partner_code, "cowswap"); } #[test] diff --git a/crates/solana-solvers/src/dex/bitget/dto.rs b/crates/solana-solvers/src/dex/bitget/dto.rs index 3483978f55..502cda5412 100644 --- a/crates/solana-solvers/src/dex/bitget/dto.rs +++ b/crates/solana-solvers/src/dex/bitget/dto.rs @@ -73,15 +73,15 @@ pub struct Envelope { } impl Envelope { - pub fn into_data(self, http_status: u16) -> Result { + pub fn into_data(self) -> 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()), + _ => Error::Dex { + code, + message: self.msg.unwrap_or_default(), }, }); } diff --git a/crates/solana-solvers/src/dex/bitget/mod.rs b/crates/solana-solvers/src/dex/bitget/mod.rs index 2d46cd686d..ab0ccf2cbe 100644 --- a/crates/solana-solvers/src/dex/bitget/mod.rs +++ b/crates/solana-solvers/src/dex/bitget/mod.rs @@ -20,7 +20,6 @@ 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 { @@ -28,6 +27,7 @@ pub struct Bitget { endpoint: reqwest::Url, api_key: String, api_secret: String, + partner_code: String, slippage_bps: u16, max_accounts: u16, } @@ -39,6 +39,7 @@ impl Bitget { endpoint: config.endpoint.clone(), api_key: config.api_key.clone(), api_secret: config.api_secret.clone(), + partner_code: config.partner_code.clone(), slippage_bps: config.slippage_bps, max_accounts: config.max_accounts, }) @@ -61,7 +62,7 @@ impl Bitget { .client .post(url) .header("content-type", "application/json") - .header("Partner-Code", PARTNER_CODE) + .header("Partner-Code", &self.partner_code) .header("x-api-key", &self.api_key) .header("x-api-timestamp", ×tamp) .header("x-api-signature", self.signature(&body, ×tamp)) @@ -81,7 +82,7 @@ impl Bitget { } serde_json::from_str::(&body) .map_err(|err| Error::BadResponse(format!("response body: {err}")))? - .into_data(status.as_u16()) + .into_data() .map(|response| response.into_quote(order)) } @@ -118,6 +119,7 @@ mod tests { .unwrap(), api_key, api_secret, + partner_code: "cowswap".into(), slippage_bps: 20, max_accounts: 48, } diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index 89336a424e..ce94cfa53e 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -173,6 +173,8 @@ pub enum Error { RateLimited, #[error("dex api error {status}: {body}")] Api { status: u16, body: String }, + #[error("dex error {code}: {message}")] + Dex { code: i64, message: String }, #[error("malformed swap response: {0}")] BadResponse(String), #[error(transparent)] From 6511e2aa6861006edf7bc0124ab760ea0a680999 Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Mon, 26 Oct 2026 13:05:29 +0530 Subject: [PATCH 7/7] check the route's signers and the taker's ATA in one pass Signed-off-by: Aryan Godara --- crates/solana-solvers/src/dex/bitget/dto.rs | 34 ++++++++++----------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/solana-solvers/src/dex/bitget/dto.rs b/crates/solana-solvers/src/dex/bitget/dto.rs index 502cda5412..9d38a27603 100644 --- a/crates/solana-solvers/src/dex/bitget/dto.rs +++ b/crates/solana-solvers/src/dex/bitget/dto.rs @@ -142,24 +142,24 @@ pub fn swap(quote: &Quote, taker: &Pubkey) -> Result { 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 atas = [inline_spl_token::ID, spl_token_2022_interface::ID].map(|program| { + let ata = get_associated_token_address_with_program_id(taker, &buy_mint, &program); + (program, ata) + }); + let mut paid = None; + for account in instructions + .iter() + .flat_map(|instruction| &instruction.accounts) + { + if account.is_signer && account.pubkey != *taker { + return Err(Error::BadResponse( + "route needs a signer besides the taker".into(), + )); + } + paid = paid.or_else(|| atas.into_iter().find(|(_, ata)| *ata == account.pubkey)); } - 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, &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()))?; + let (token_program, output) = + paid.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)]