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
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion crates/solana-solvers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -21,14 +21,19 @@ 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 }
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 }
Expand Down
9 changes: 6 additions & 3 deletions crates/solana-solvers/config/example.bitget.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ 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"
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
18 changes: 14 additions & 4 deletions crates/solana-solvers/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,25 @@ 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,

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.
Expand Down Expand Up @@ -166,7 +175,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]
Expand Down
15 changes: 0 additions & 15 deletions crates/solana-solvers/src/dex/bitget.rs

This file was deleted.

240 changes: 240 additions & 0 deletions crates/solana-solvers/src/dex/bitget/dto.rs
Original file line number Diff line number Diff line change
@@ -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<i64>,
msg: Option<String>,
data: Option<serde_json::Value>,
}

impl Envelope {
pub fn into_data(self, http_status: u16) -> Result<SwapResponse, Error> {
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<ApiInstruction>,
#[serde_as(as = "Vec<DisplayFromStr>")]
address_lookup_table_account: Vec<Pubkey>,
Comment on lines +101 to +103

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.

Unlike the Jupiter DTO (which marks address_lookup_table_addresses, setup_instructions, and cleanup_instruction with #[serde(default)]), neither instruction_lists nor address_lookup_table_account has a default here. If Bitget ever omits (or sends null for) addressLookupTableAccount — e.g. a direct route that uses no lookup tables — deserialization fails outright and the order is silently dropped as BadResponse instead of being quoted. Consider adding #[serde(default)] to be defensive and consistent with the Jupiter adapter:

Suggested change
instruction_lists: Vec<ApiInstruction>,
#[serde_as(as = "Vec<DisplayFromStr>")]
address_lookup_table_account: Vec<Pubkey>,
instruction_lists: Vec<ApiInstruction>,
#[serde(default)]
#[serde_as(as = "Vec<DisplayFromStr>")]
address_lookup_table_account: Vec<Pubkey>,

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.

"Every /swap response i saw had addressLookupTableAccount, and a missing field already logs as quote failed.

}

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<Swap, Error> {
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<Pubkey>,
) -> 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(_))
));
}
}
Loading
Loading