Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions crates/solana-solvers/config/example.bitget.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Example Bitget solver configuration.
# Run with: solana-solvers bitget --config <this file>

# 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. 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
5 changes: 5 additions & 0 deletions crates/solana-solvers/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
62 changes: 51 additions & 11 deletions crates/solana-solvers/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<D> {
/// Orders quoted at once.
#[serde(default = "default_concurrent_requests")]
pub concurrent_requests: NonZeroUsize,

Expand All @@ -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<D> Config<D> {
/// The back-off applied to rate-limited DEX responses.
///
/// # Panics
///
Expand Down Expand Up @@ -85,12 +85,28 @@ 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. `swap` is joined onto it, so it needs
/// the trailing slash.
pub endpoint: Url,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking / for the next PR: correctness here relies entirely on a prose comment ("needs the trailing slash"). Because Url::join("swap") replaces the last path segment when the base lacks a trailing slash, a deploy config of .../swapx/pro (no slash) would silently resolve to .../swapx/swap rather than .../swapx/pro/swap — a wrong endpoint with no error. Worth validating/normalizing the trailing slash in the Bitget constructor when the swap adapter lands (BE-253), rather than trusting operators to read the comment.


pub api_key: String,

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<D: DeserializeOwned>(path: &Path) -> Config<D> {
let text = tokio::fs::read_to_string(path)
.await
.unwrap_or_else(|err| panic!("read config {}: {err}", path.display()));
Expand All @@ -103,7 +119,7 @@ mod tests {

#[test]
fn parses_example_config() {
let config: Config =
let config: Config<JupiterConfig> =
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);
Expand All @@ -123,7 +139,7 @@ mod tests {
endpoint = "https://api.jup.ag"
slippage-bps = 50
"#;
let config: Config = toml::from_str(toml).unwrap();
let config: Config<JupiterConfig> = 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);
Expand All @@ -139,6 +155,30 @@ endpoint = "https://api.jup.ag"
slippage-bps = 50
bogus = true
"#;
assert!(toml::from_str::<Config>(toml).is_err());
assert!(toml::from_str::<Config<JupiterConfig>>(toml).is_err());
}

#[test]
fn parses_bitget_example_config() {
let config: Config<BitgetConfig> =
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() {
// 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::<Config<BitgetConfig>>(toml).is_err());
}
}
15 changes: 15 additions & 0 deletions crates/solana-solvers/src/dex/bitget.rs
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is done in next PR

pub async fn swap(&self, _order: &Order, _taker: &Pubkey) -> Result<Swap, Error> {
Err(Error::NotFound)
}
}
20 changes: 1 addition & 19 deletions crates/solana-solvers/src/dex/jupiter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
mod dto;

use {
super::{Order, Side, Swap},
super::{Error, Order, Side, Swap},
crate::config::JupiterConfig,
solana_sdk::pubkey::Pubkey,
};
Expand Down Expand Up @@ -145,24 +145,6 @@ fn amount_field(quote: &serde_json::Value, field: &str) -> Result<u64, Error> {
.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};
Expand Down
23 changes: 22 additions & 1 deletion crates/solana-solvers/src/dex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//!
//! `Dex` dispatches to the configured engine.

pub mod bitget;
pub mod jupiter;

use solana_sdk::{instruction::Instruction, pubkey::Pubkey};
Expand Down Expand Up @@ -72,6 +73,7 @@ impl Swap {
/// The configured DEX backend.
pub enum Dex {
Jupiter(jupiter::Jupiter),
Bitget(bitget::Bitget),
}

impl Dex {
Expand All @@ -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<Swap, jupiter::Error> {
pub async fn swap(&self, order: &Order, user: &Pubkey) -> Result<Swap, Error> {
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::*;
Expand Down
22 changes: 10 additions & 12 deletions crates/solana-solvers/src/domain/solver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ pub trait Quote {
&self,
order: &dex::Order,
taker: &Pubkey,
) -> impl Future<Output = Result<dex::Swap, dex::jupiter::Error>> + Send;
) -> impl Future<Output = Result<dex::Swap, dex::Error>> + Send;
}

impl Quote for Dex {
fn quote(
&self,
order: &dex::Order,
taker: &Pubkey,
) -> impl Future<Output = Result<dex::Swap, dex::jupiter::Error>> + Send {
) -> impl Future<Output = Result<dex::Swap, dex::Error>> + Send {
self.swap(order, taker)
}
}
Expand Down Expand Up @@ -101,16 +101,16 @@ impl<Q: Quote + Sync> Solver<Q> {
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()?;
Expand Down Expand Up @@ -211,7 +211,7 @@ mod tests {
&self,
order: &dex::Order,
_taker: &Pubkey,
) -> impl Future<Output = Result<dex::Swap, dex::jupiter::Error>> + Send {
) -> impl Future<Output = Result<dex::Swap, dex::Error>> + 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.
Expand All @@ -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) {
Expand Down
44 changes: 26 additions & 18 deletions crates/solana-solvers/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,38 @@ pub async fn start(args: impl IntoIterator<Item = String>) {
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::<config::JupiterConfig>(&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::<config::BitgetConfig>(&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");
}
}
Loading