From e6cbb3ca51ddd8ba601123b25698a3ff650cb57a Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Tue, 13 Oct 2026 18:39:46 +0100 Subject: [PATCH 1/2] 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 7e4ddf5b643a1c575bcae0c0a86b1bbef372597e Mon Sep 17 00:00:00 2001 From: Aryan Godara Date: Fri, 16 Oct 2026 17:08:51 +0100 Subject: [PATCH 2/2] 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/"