Skip to content

Commit 297f53b

Browse files
committed
feat(solana-solvers): solve loop, order handling, engine wiring
1 parent 7385b72 commit 297f53b

9 files changed

Lines changed: 256 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/solana-solvers/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ axum = { workspace = true }
1818
base64 = { workspace = true }
1919
clap = { workspace = true, features = ["derive", "env"] }
2020
const-hex = { workspace = true }
21+
futures = { workspace = true }
2122
observe = { workspace = true }
2223
reqwest = { workspace = true }
2324
serde = { workspace = true, features = ["derive"] }

crates/solana-solvers/src/api.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
//! HTTP API for the solver engine.
22
//!
3-
//! Serves the `/solve` contract the driver calls. The handler is a scaffold: it
4-
//! accepts any auction and returns no solutions.
3+
//! Serves the `/solve` contract the driver calls.
54
65
use {
7-
crate::config::Config,
6+
crate::{
7+
dex::Dex,
8+
domain::{auction::Auction, solver},
9+
},
810
axum::{
911
Json,
1012
Router,
@@ -20,7 +22,7 @@ const REQUEST_BODY_LIMIT: usize = 10 * 1024 * 1024;
2022

2123
pub struct Api {
2224
pub addr: SocketAddr,
23-
pub config: Config,
25+
pub dex: Arc<Dex>,
2426
}
2527

2628
impl Api {
@@ -32,7 +34,7 @@ impl Api {
3234
let app = Router::new()
3335
.route("/healthz", get(healthz))
3436
.route("/solve", post(solve))
35-
.with_state(Arc::new(self.config))
37+
.with_state(self.dex)
3638
.layer(RequestBodyLimitLayer::new(REQUEST_BODY_LIMIT))
3739
.layer(axum::extract::DefaultBodyLimit::disable());
3840

@@ -48,8 +50,8 @@ async fn healthz() -> &'static str {
4850
"ok"
4951
}
5052

51-
/// Scaffold `/solve`: accepts any auction and returns no solutions, so the
52-
/// driver wiring can be exercised against an empty result.
53-
async fn solve(State(_config): State<Arc<Config>>, Json(_auction): Json<Value>) -> Json<Value> {
54-
Json(json!({ "solutions": [] }))
53+
/// Quote every order in the auction and return the single-order solutions.
54+
async fn solve(State(dex): State<Arc<Dex>>, Json(auction): Json<Auction>) -> Json<Value> {
55+
let solutions = solver::solve(dex.as_ref(), &auction).await;
56+
Json(json!({ "solutions": solutions }))
5557
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ pub struct Order {
2020
pub side: Side,
2121
}
2222

23-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23+
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
24+
#[serde(rename_all = "camelCase")]
2425
pub enum Side {
2526
Buy,
2627
Sell,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//! Inbound `/solve` auction: the orders the driver asks the solver to fill.
2+
//!
3+
//! Proposed shape. The driver spec pins the solution response, not the auction
4+
//! request, so these are the fields the solve loop needs and will be reconciled
5+
//! with the driver's request DTO.
6+
7+
use {
8+
super::order::OrderUid,
9+
crate::dex,
10+
serde::Deserialize,
11+
serde_with::serde_as,
12+
solana_sdk::pubkey::Pubkey,
13+
};
14+
15+
/// The auction the driver posts to `/solve`.
16+
#[serde_as]
17+
#[derive(Debug, Deserialize)]
18+
#[serde(rename_all = "camelCase")]
19+
pub struct Auction {
20+
pub id: u64,
21+
/// Settlement signer the swap instructions are built for.
22+
#[serde_as(as = "serde_with::DisplayFromStr")]
23+
pub taker: Pubkey,
24+
pub orders: Vec<Order>,
25+
}
26+
27+
/// One order to quote.
28+
#[serde_as]
29+
#[derive(Debug, Deserialize)]
30+
#[serde(rename_all = "camelCase")]
31+
pub struct Order {
32+
#[serde_as(as = "serde_with::DisplayFromStr")]
33+
pub uid: OrderUid,
34+
#[serde_as(as = "serde_with::DisplayFromStr")]
35+
pub sell_mint: Pubkey,
36+
#[serde_as(as = "serde_with::DisplayFromStr")]
37+
pub buy_mint: Pubkey,
38+
/// The buy-mint buffer the swap output lands in.
39+
#[serde_as(as = "serde_with::DisplayFromStr")]
40+
pub buy_destination: Pubkey,
41+
/// Sell amount for a sell, buy amount for a buy. Decimal string on the
42+
/// wire.
43+
#[serde_as(as = "serde_with::DisplayFromStr")]
44+
pub amount: u64,
45+
pub side: dex::Side,
46+
}
47+
48+
impl Order {
49+
/// The adapter-facing view of this order.
50+
pub fn to_dex_order(&self) -> dex::Order {
51+
dex::Order {
52+
sell_mint: self.sell_mint,
53+
buy_mint: self.buy_mint,
54+
buy_destination: self.buy_destination,
55+
amount: self.amount,
56+
side: self.side,
57+
}
58+
}
59+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
//! Domain types the solver engine emits: the solution DTO the driver consumes.
1+
//! Domain types and the solve loop: parse the auction, quote each order, and
2+
//! assemble the solutions the driver consumes.
23
4+
pub mod auction;
35
pub mod order;
46
pub mod solution;
7+
pub mod solver;

crates/solana-solvers/src/domain/order.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,13 @@ impl serde::Serialize for OrderUid {
2929
serializer.collect_str(self)
3030
}
3131
}
32+
33+
impl std::str::FromStr for OrderUid {
34+
type Err = const_hex::FromHexError;
35+
36+
fn from_str(s: &str) -> Result<Self, Self::Err> {
37+
let mut bytes = [0u8; 32];
38+
const_hex::decode_to_slice(s.strip_prefix("0x").unwrap_or(s), &mut bytes)?;
39+
Ok(Self(bytes))
40+
}
41+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//! Solve loop: quote each auction order and assemble single-order solutions.
2+
3+
use {
4+
super::{auction::Auction, solution::Solution},
5+
crate::dex::{self, Dex},
6+
futures::future::join_all,
7+
solana_sdk::pubkey::Pubkey,
8+
std::future::Future,
9+
};
10+
11+
/// Quotes one order into a swap. A seam over [`Dex`] so the loop is testable
12+
/// without the network.
13+
pub trait Quote {
14+
fn quote(
15+
&self,
16+
order: &dex::Order,
17+
taker: &Pubkey,
18+
) -> impl Future<Output = Result<dex::Swap, dex::jupiter::Error>> + Send;
19+
}
20+
21+
impl Quote for Dex {
22+
fn quote(
23+
&self,
24+
order: &dex::Order,
25+
taker: &Pubkey,
26+
) -> impl Future<Output = Result<dex::Swap, dex::jupiter::Error>> + Send {
27+
self.swap(order, taker)
28+
}
29+
}
30+
31+
/// Quote every order concurrently and return one single-order solution per
32+
/// routable order. Buys (when disabled) and orders the aggregator cannot route
33+
/// yield no candidate, the rest of the auction still proceeds.
34+
///
35+
/// Order counts are small (bounded by the settlement account budget), so every
36+
/// order is quoted at once.
37+
pub async fn solve<Q: Quote>(quoter: &Q, auction: &Auction) -> Vec<Solution> {
38+
let candidates = auction.orders.iter().enumerate().map(|(index, order)| {
39+
let dex_order = order.to_dex_order();
40+
async move {
41+
let swap = quoter.quote(&dex_order, &auction.taker).await.ok()?;
42+
Solution::single(index as u64, order.uid, &dex_order, swap).ok()
43+
}
44+
});
45+
join_all(candidates).await.into_iter().flatten().collect()
46+
}
47+
48+
#[cfg(test)]
49+
mod tests {
50+
use {
51+
super::*,
52+
crate::{
53+
config::JupiterConfig,
54+
domain::{auction, order::OrderUid},
55+
},
56+
std::str::FromStr,
57+
};
58+
59+
// USDC and wrapped SOL mints for the live test.
60+
const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
61+
const WSOL: &str = "So11111111111111111111111111111111111111112";
62+
63+
fn pubkey(byte: u8) -> Pubkey {
64+
Pubkey::new_from_array([byte; 32])
65+
}
66+
67+
fn order(uid: u8, side: dex::Side, sell_mint: Pubkey) -> auction::Order {
68+
auction::Order {
69+
uid: OrderUid([uid; 32]),
70+
sell_mint,
71+
buy_mint: pubkey(2),
72+
buy_destination: pubkey(3),
73+
amount: 1_000,
74+
side,
75+
}
76+
}
77+
78+
/// Routes any sell except the `0xff` sell mint, rejects buys.
79+
struct MockQuote;
80+
81+
impl Quote for MockQuote {
82+
fn quote(
83+
&self,
84+
order: &dex::Order,
85+
_taker: &Pubkey,
86+
) -> impl Future<Output = Result<dex::Swap, dex::jupiter::Error>> + Send {
87+
let result = match order.side {
88+
dex::Side::Buy => Err(dex::jupiter::Error::OrderNotSupported),
89+
dex::Side::Sell if order.sell_mint == pubkey(0xff) => {
90+
Err(dex::jupiter::Error::NotFound)
91+
}
92+
dex::Side::Sell => Ok(dex::Swap {
93+
in_amount: 1_000,
94+
out_amount: 2_000,
95+
instructions: vec![],
96+
address_lookup_tables: vec![],
97+
}),
98+
};
99+
async move { result }
100+
}
101+
}
102+
103+
#[tokio::test]
104+
async fn emits_one_solution_per_routable_order() {
105+
let auction = Auction {
106+
id: 1,
107+
taker: pubkey(1),
108+
orders: vec![
109+
order(0x01, dex::Side::Sell, pubkey(0x10)), // routable
110+
order(0x02, dex::Side::Sell, pubkey(0xff)), // no route
111+
order(0x03, dex::Side::Buy, pubkey(0x11)), // buys disabled
112+
],
113+
};
114+
115+
let solutions = solve(&MockQuote, &auction).await;
116+
117+
assert_eq!(solutions.len(), 1);
118+
assert_eq!(solutions[0].trades[0].order_uid, OrderUid([0x01; 32]));
119+
}
120+
121+
#[tokio::test]
122+
async fn empty_auction_yields_no_solutions() {
123+
let auction = Auction {
124+
id: 1,
125+
taker: pubkey(1),
126+
orders: vec![],
127+
};
128+
assert!(solve(&MockQuote, &auction).await.is_empty());
129+
}
130+
131+
/// Live Jupiter API. Needs network. Keyless works, set `JUPITER_API_KEY`
132+
/// for headroom.
133+
#[tokio::test]
134+
#[ignore]
135+
async fn jupiter_live_solve() {
136+
let dex = Dex::Jupiter(
137+
dex::jupiter::Jupiter::new(&JupiterConfig {
138+
endpoint: "https://api.jup.ag".parse().unwrap(),
139+
api_key: std::env::var("JUPITER_API_KEY").ok(),
140+
slippage_bps: 50,
141+
enable_buy_orders: false,
142+
})
143+
.unwrap(),
144+
);
145+
let auction = Auction {
146+
id: 1,
147+
taker: Pubkey::from_str(WSOL).unwrap(),
148+
orders: vec![auction::Order {
149+
uid: OrderUid([7; 32]),
150+
sell_mint: Pubkey::from_str(USDC).unwrap(),
151+
buy_mint: Pubkey::from_str(WSOL).unwrap(),
152+
buy_destination: Pubkey::from_str(WSOL).unwrap(),
153+
amount: 1_000_000,
154+
side: dex::Side::Sell,
155+
}],
156+
};
157+
158+
let solutions = solve(&dex, &auction).await;
159+
160+
assert_eq!(solutions.len(), 1);
161+
assert!(!solutions[0].interactions.is_empty());
162+
}
163+
}

crates/solana-solvers/src/run.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ use {
77
api::Api,
88
cli::{Args, Command},
99
config,
10+
dex,
1011
},
1112
clap::Parser,
13+
std::sync::Arc,
1214
};
1315

1416
/// Parse args and run the selected solver engine until shutdown.
@@ -28,9 +30,11 @@ pub async fn start(args: impl IntoIterator<Item = String>) {
2830
match args.command {
2931
Command::Jupiter { config: path } => {
3032
let config = config::load(&path).await;
33+
let jupiter = dex::jupiter::Jupiter::new(&config.dex)
34+
.unwrap_or_else(|err| panic!("build jupiter dex: {err}"));
3135
let api = Api {
3236
addr: args.addr,
33-
config,
37+
dex: Arc::new(dex::Dex::Jupiter(jupiter)),
3438
};
3539
if let Err(err) = api.serve(shutdown_signal()).await {
3640
tracing::error!(?err, "server error");

0 commit comments

Comments
 (0)