Skip to content

Commit fa36489

Browse files
committed
feat(solana-solvers): buy orders via v1 ExactOut, gated by enable_buy_orders
1 parent 784b8c5 commit fa36489

3 files changed

Lines changed: 60 additions & 21 deletions

File tree

crates/solana-solvers/config/example.jupiter.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ endpoint = "https://api.jup.ag"
99
api-key = "your-jupiter-api-key"
1010
# Slippage tolerance in basis points, sent to Jupiter as slippageBps. 50 = 0.5%.
1111
slippage-bps = 50
12+
# Serve buy orders via ExactOut swaps. Off by default.
13+
enable-buy-orders = false

crates/solana-solvers/src/config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ pub struct JupiterConfig {
2525
/// Slippage tolerance in basis points, sent to Jupiter as `slippageBps`.
2626
/// 50 = 0.5%.
2727
pub slippage_bps: u16,
28+
29+
/// Serve buy orders via ExactOut swaps. Off by default.
30+
#[serde(default)]
31+
pub enable_buy_orders: bool,
2832
}
2933

3034
/// Load and parse the TOML config file.
@@ -50,6 +54,7 @@ mod tests {
5054
assert_eq!(config.dex.endpoint.as_str(), "https://api.jup.ag/");
5155
assert_eq!(config.dex.slippage_bps, 50);
5256
assert!(config.dex.api_key.is_some());
57+
assert!(!config.dex.enable_buy_orders);
5358
}
5459

5560
#[test]

crates/solana-solvers/src/dex/jupiter/mod.rs

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Jupiter swap-API adapter.
22
//!
3-
//! v1 `/quote` + `/swap-instructions` (ExactIn). Triton is a base-URL and key
4-
//! swap behind the same adapter.
3+
//! v1 `/quote` + `/swap-instructions` (ExactIn for sells, ExactOut for buys).
4+
//! Triton is a base-URL and key swap behind the same adapter.
55
66
mod dto;
77

@@ -14,12 +14,29 @@ use {
1414
const QUOTE_PATH: &str = "swap/v1/quote";
1515
const SWAP_INSTRUCTIONS_PATH: &str = "swap/v1/swap-instructions";
1616

17+
/// Jupiter's quote direction, rendered as the `swapMode` query value.
18+
#[derive(Clone, Copy)]
19+
enum SwapMode {
20+
ExactIn,
21+
ExactOut,
22+
}
23+
24+
impl SwapMode {
25+
fn as_str(self) -> &'static str {
26+
match self {
27+
SwapMode::ExactIn => "ExactIn",
28+
SwapMode::ExactOut => "ExactOut",
29+
}
30+
}
31+
}
32+
1733
/// Adapter over the Jupiter swap API.
1834
pub struct Jupiter {
1935
client: reqwest::Client,
2036
endpoint: reqwest::Url,
2137
api_key: Option<String>,
2238
slippage_bps: u16,
39+
enable_buy_orders: bool,
2340
}
2441

2542
impl Jupiter {
@@ -29,17 +46,19 @@ impl Jupiter {
2946
endpoint: config.endpoint.clone(),
3047
api_key: config.api_key.clone(),
3148
slippage_bps: config.slippage_bps,
49+
enable_buy_orders: config.enable_buy_orders,
3250
})
3351
}
3452

3553
/// Quote `order` for the settlement signer `taker` and return the swap to
3654
/// run inside the settlement transaction.
3755
pub async fn swap(&self, order: &Order, taker: &Pubkey) -> Result<Swap, Error> {
38-
// Buy orders (ExactOut) aren't served here.
39-
if order.side == Side::Buy {
40-
return Err(Error::OrderNotSupported);
41-
}
42-
let quote = self.quote(order, "ExactIn").await?;
56+
let swap_mode = match order.side {
57+
Side::Sell => SwapMode::ExactIn,
58+
Side::Buy if self.enable_buy_orders => SwapMode::ExactOut,
59+
Side::Buy => return Err(Error::OrderNotSupported),
60+
};
61+
let quote = self.quote(order, swap_mode).await?;
4362
let in_amount = amount_field(&quote, "inAmount")?;
4463
let out_amount = amount_field(&quote, "outAmount")?;
4564
self.swap_instructions(&quote, taker, &order.buy_destination)
@@ -49,7 +68,7 @@ impl Jupiter {
4968

5069
/// `GET /swap/v1/quote`. Kept opaque and passed back verbatim to
5170
/// `/swap-instructions`, we only read the amounts.
52-
async fn quote(&self, order: &Order, swap_mode: &str) -> Result<serde_json::Value, Error> {
71+
async fn quote(&self, order: &Order, swap_mode: SwapMode) -> Result<serde_json::Value, Error> {
5372
let mut url = self
5473
.endpoint
5574
.join(QUOTE_PATH)
@@ -58,7 +77,7 @@ impl Jupiter {
5877
.append_pair("inputMint", &order.sell_mint.to_string())
5978
.append_pair("outputMint", &order.buy_mint.to_string())
6079
.append_pair("amount", &order.amount.to_string())
61-
.append_pair("swapMode", swap_mode)
80+
.append_pair("swapMode", swap_mode.as_str())
6281
.append_pair("slippageBps", &self.slippage_bps.to_string());
6382
self.send(self.with_key(self.client.get(url))).await
6483
}
@@ -150,32 +169,29 @@ mod tests {
150169
const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
151170
const WSOL: &str = "So11111111111111111111111111111111111111112";
152171

153-
fn config() -> JupiterConfig {
172+
fn config(enable_buy_orders: bool) -> JupiterConfig {
154173
JupiterConfig {
155174
endpoint: "https://api.jup.ag".parse().unwrap(),
156175
api_key: std::env::var("JUPITER_API_KEY").ok(),
157176
slippage_bps: 50,
177+
enable_buy_orders,
158178
}
159179
}
160180

161-
fn sell_order() -> Order {
181+
fn order(side: Side) -> Order {
162182
Order {
163183
sell_mint: Pubkey::from_str(USDC).unwrap(),
164184
buy_mint: Pubkey::from_str(WSOL).unwrap(),
165185
buy_destination: Pubkey::from_str(WSOL).unwrap(),
166186
amount: 1_000_000,
167-
side: Side::Sell,
187+
side,
168188
}
169189
}
170190

171191
#[tokio::test]
172-
async fn buy_unsupported() {
173-
let jupiter = Jupiter::new(&config()).unwrap();
174-
let order = Order {
175-
side: Side::Buy,
176-
..sell_order()
177-
};
178-
let result = jupiter.swap(&order, &Pubkey::new_unique()).await;
192+
async fn buy_disabled() {
193+
let jupiter = Jupiter::new(&config(false)).unwrap();
194+
let result = jupiter.swap(&order(Side::Buy), &Pubkey::new_unique()).await;
179195
assert!(matches!(result, Err(Error::OrderNotSupported)));
180196
}
181197

@@ -217,15 +233,31 @@ mod tests {
217233
#[tokio::test]
218234
#[ignore]
219235
async fn jupiter_live_sell() {
220-
let jupiter = Jupiter::new(&config()).unwrap();
236+
let jupiter = Jupiter::new(&config(false)).unwrap();
221237
// Any valid pubkey works for building instructions, the swap only runs
222238
// for real once the driver supplies its settlement signer.
223239
let swap = jupiter
224-
.swap(&sell_order(), &Pubkey::from_str(WSOL).unwrap())
240+
.swap(&order(Side::Sell), &Pubkey::from_str(WSOL).unwrap())
225241
.await
226242
.unwrap();
227243
assert_eq!(swap.in_amount, 1_000_000);
228244
assert!(swap.out_amount > 0);
229245
assert!(!swap.instructions.is_empty());
230246
}
247+
248+
/// Live Jupiter API. Needs network. Keyless works, set `JUPITER_API_KEY`
249+
/// for headroom.
250+
#[tokio::test]
251+
#[ignore]
252+
async fn jupiter_live_buy() {
253+
let jupiter = Jupiter::new(&config(true)).unwrap();
254+
// ExactOut: the swap delivers exactly the order's `amount` of buy mint.
255+
let swap = jupiter
256+
.swap(&order(Side::Buy), &Pubkey::from_str(WSOL).unwrap())
257+
.await
258+
.unwrap();
259+
assert_eq!(swap.out_amount, 1_000_000);
260+
assert!(swap.in_amount > 0);
261+
assert!(!swap.instructions.is_empty());
262+
}
231263
}

0 commit comments

Comments
 (0)