diff --git a/Cargo.lock b/Cargo.lock index 1a16335142..26fdbf50e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10836,6 +10836,7 @@ dependencies = [ "base64 0.22.1", "clap", "const-hex", + "futures", "observe", "reqwest 0.13.4", "serde", diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index d05629b790..0eacadea52 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -18,6 +18,7 @@ axum = { workspace = true } base64 = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } const-hex = { workspace = true } +futures = { workspace = true } observe = { workspace = true } reqwest = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/solana-solvers/src/api.rs b/crates/solana-solvers/src/api.rs index 26a6024690..995e9064e7 100644 --- a/crates/solana-solvers/src/api.rs +++ b/crates/solana-solvers/src/api.rs @@ -1,10 +1,9 @@ //! HTTP API for the solver engine. //! -//! Serves the `/solve` contract the driver calls. The handler is a scaffold: it -//! accepts any auction and returns no solutions. +//! Serves the `/solve` contract the driver calls. use { - crate::config::Config, + crate::{dex::Dex, domain::solver, dto::auction::Auction}, axum::{ Json, Router, @@ -20,7 +19,7 @@ const REQUEST_BODY_LIMIT: usize = 10 * 1024 * 1024; pub struct Api { pub addr: SocketAddr, - pub config: Config, + pub dex: Arc, } impl Api { @@ -32,7 +31,7 @@ impl Api { let app = Router::new() .route("/healthz", get(healthz)) .route("/solve", post(solve)) - .with_state(Arc::new(self.config)) + .with_state(self.dex) .layer(RequestBodyLimitLayer::new(REQUEST_BODY_LIMIT)) .layer(axum::extract::DefaultBodyLimit::disable()); @@ -48,8 +47,8 @@ async fn healthz() -> &'static str { "ok" } -/// Scaffold `/solve`: accepts any auction and returns no solutions, so the -/// driver wiring can be exercised against an empty result. -async fn solve(State(_config): State>, Json(_auction): Json) -> Json { - Json(json!({ "solutions": [] })) +/// Quote every order in the auction and return the single-order solutions. +async fn solve(State(dex): State>, Json(auction): Json) -> Json { + let solutions = solver::solve(dex.as_ref(), &auction).await; + Json(json!({ "solutions": solutions })) } diff --git a/crates/solana-solvers/src/dex/mod.rs b/crates/solana-solvers/src/dex/mod.rs index ccdc1bdd4a..96c5f7fa6b 100644 --- a/crates/solana-solvers/src/dex/mod.rs +++ b/crates/solana-solvers/src/dex/mod.rs @@ -20,7 +20,8 @@ pub struct Order { pub side: Side, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub enum Side { Buy, Sell, diff --git a/crates/solana-solvers/src/domain/mod.rs b/crates/solana-solvers/src/domain/mod.rs new file mode 100644 index 0000000000..e98b7ee847 --- /dev/null +++ b/crates/solana-solvers/src/domain/mod.rs @@ -0,0 +1,4 @@ +//! Domain logic: the solve loop that quotes each auction order and assembles +//! the solutions the driver consumes. + +pub mod solver; diff --git a/crates/solana-solvers/src/domain/solver/mod.rs b/crates/solana-solvers/src/domain/solver/mod.rs new file mode 100644 index 0000000000..0c908fb44f --- /dev/null +++ b/crates/solana-solvers/src/domain/solver/mod.rs @@ -0,0 +1,124 @@ +//! Solve loop: quote each auction order and assemble single-order solutions. + +use { + crate::{ + dex::{self, Dex}, + dto::{auction::Auction, solution::Solution}, + }, + futures::future::join_all, + solana_sdk::pubkey::Pubkey, + std::future::Future, +}; + +/// Quotes one order into a swap. A seam over [`Dex`] so the loop is testable +/// without the network. +pub trait Quote { + fn quote( + &self, + order: &dex::Order, + taker: &Pubkey, + ) -> impl Future> + Send; +} + +impl Quote for Dex { + fn quote( + &self, + order: &dex::Order, + taker: &Pubkey, + ) -> impl Future> + Send { + self.swap(order, taker) + } +} + +/// Quote every order concurrently and return one single-order solution per +/// routable order. Buys (when disabled) and orders the aggregator cannot route +/// yield no candidate, the rest of the auction still proceeds. +/// +/// Order counts are small (bounded by the settlement account budget), so every +/// order is quoted at once. +pub async fn solve(quoter: &Q, auction: &Auction) -> Vec { + let candidates = auction.orders.iter().enumerate().map(|(index, order)| { + let dex_order = order.to_dex_order(); + async move { + let swap = quoter.quote(&dex_order, &auction.taker).await.ok()?; + Solution::new(index as u64, order.uid, &dex_order, swap).ok() + } + }); + join_all(candidates).await.into_iter().flatten().collect() +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::dto::{auction, order::OrderUid}, + }; + + fn pubkey(byte: u8) -> Pubkey { + Pubkey::new_from_array([byte; 32]) + } + + fn order(uid: u8, side: dex::Side, sell_mint: Pubkey) -> auction::Order { + auction::Order { + uid: OrderUid([uid; 32]), + sell_mint, + buy_mint: pubkey(2), + buy_destination: pubkey(3), + amount: 1_000, + side, + } + } + + /// Routes any sell except the `0xff` sell mint, rejects buys. + struct MockQuote; + + impl Quote for MockQuote { + fn quote( + &self, + order: &dex::Order, + _taker: &Pubkey, + ) -> impl Future> + Send { + let result = match order.side { + dex::Side::Buy => Err(dex::jupiter::Error::OrderNotSupported), + dex::Side::Sell if order.sell_mint == pubkey(0xff) => { + Err(dex::jupiter::Error::NotFound) + } + dex::Side::Sell => Ok(dex::Swap { + in_amount: 1_000, + out_amount: 2_000, + instructions: vec![], + address_lookup_tables: vec![], + }), + }; + async move { result } + } + } + + #[tokio::test] + async fn emits_one_solution_per_routable_order() { + let auction = Auction { + id: 1, + taker: pubkey(1), + orders: vec![ + order(0x01, dex::Side::Sell, pubkey(0x10)), // routable + order(0x02, dex::Side::Sell, pubkey(0xff)), // no route + order(0x03, dex::Side::Buy, pubkey(0x11)), // buys disabled + ], + }; + + let solutions = solve(&MockQuote, &auction).await; + + assert_eq!(solutions.len(), 1); + assert_eq!(solutions[0].trades[0].order_uid, OrderUid([0x01; 32])); + } + + #[tokio::test] + async fn empty_auction_yields_no_solutions() { + let auction = Auction { + id: 1, + taker: pubkey(1), + orders: vec![], + }; + assert!(solve(&MockQuote, &auction).await.is_empty()); + } +} diff --git a/crates/solana-solvers/src/dto/auction.rs b/crates/solana-solvers/src/dto/auction.rs new file mode 100644 index 0000000000..f850a55439 --- /dev/null +++ b/crates/solana-solvers/src/dto/auction.rs @@ -0,0 +1,59 @@ +//! Inbound `/solve` auction: the orders the driver asks the solver to fill. +//! +//! Proposed shape. The driver spec pins the solution response, not the auction +//! request, so these are the fields the solve loop needs and will be reconciled +//! with the driver's request DTO. + +use { + super::order::OrderUid, + crate::dex, + serde::Deserialize, + serde_with::serde_as, + solana_sdk::pubkey::Pubkey, +}; + +/// The auction the driver posts to `/solve`. +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Auction { + pub id: u64, + /// Settlement signer the swap instructions are built for. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub taker: Pubkey, + pub orders: Vec, +} + +/// One order to quote. +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + #[serde_as(as = "serde_with::DisplayFromStr")] + pub uid: OrderUid, + #[serde_as(as = "serde_with::DisplayFromStr")] + pub sell_mint: Pubkey, + #[serde_as(as = "serde_with::DisplayFromStr")] + pub buy_mint: Pubkey, + /// The buy-mint buffer the swap output lands in. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub buy_destination: Pubkey, + /// Sell amount for a sell, buy amount for a buy. Decimal string on the + /// wire. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub amount: u64, + pub side: dex::Side, +} + +impl Order { + /// The adapter-facing view of this order. + pub fn to_dex_order(&self) -> dex::Order { + dex::Order { + sell_mint: self.sell_mint, + buy_mint: self.buy_mint, + buy_destination: self.buy_destination, + amount: self.amount, + side: self.side, + } + } +} diff --git a/crates/solana-solvers/src/dto/mod.rs b/crates/solana-solvers/src/dto/mod.rs index 1c2cdc1648..f48c86e316 100644 --- a/crates/solana-solvers/src/dto/mod.rs +++ b/crates/solana-solvers/src/dto/mod.rs @@ -1,4 +1,6 @@ -//! Wire DTOs the solver engine emits: the solution the driver consumes. +//! Wire DTOs: the inbound `/solve` auction the driver posts and the solution +//! the solver emits back. +pub mod auction; pub mod order; pub mod solution; diff --git a/crates/solana-solvers/src/dto/order.rs b/crates/solana-solvers/src/dto/order.rs index 487faeb762..0a83e50ce8 100644 --- a/crates/solana-solvers/src/dto/order.rs +++ b/crates/solana-solvers/src/dto/order.rs @@ -25,3 +25,13 @@ impl serde::Serialize for OrderUid { serializer.collect_str(self) } } + +impl std::str::FromStr for OrderUid { + type Err = const_hex::FromHexError; + + fn from_str(s: &str) -> Result { + let mut bytes = [0u8; 32]; + const_hex::decode_to_slice(s.strip_prefix("0x").unwrap_or(s), &mut bytes)?; + Ok(Self(bytes)) + } +} diff --git a/crates/solana-solvers/src/lib.rs b/crates/solana-solvers/src/lib.rs index 3e7996bf05..fd520b4605 100644 --- a/crates/solana-solvers/src/lib.rs +++ b/crates/solana-solvers/src/lib.rs @@ -7,6 +7,7 @@ pub mod api; mod cli; pub mod config; pub mod dex; +pub mod domain; pub mod dto; mod run; diff --git a/crates/solana-solvers/src/run.rs b/crates/solana-solvers/src/run.rs index 1168f474c4..4c504fb07f 100644 --- a/crates/solana-solvers/src/run.rs +++ b/crates/solana-solvers/src/run.rs @@ -7,8 +7,10 @@ use { api::Api, cli::{Args, Command}, config, + dex, }, clap::Parser, + std::sync::Arc, }; /// Parse args and run the selected solver engine until shutdown. @@ -28,9 +30,11 @@ pub async fn start(args: impl IntoIterator) { match args.command { Command::Jupiter { config: path } => { let config = config::load(&path).await; + let jupiter = dex::jupiter::Jupiter::new(&config.dex) + .unwrap_or_else(|err| panic!("build jupiter dex: {err}")); let api = Api { addr: args.addr, - config, + dex: Arc::new(dex::Dex::Jupiter(jupiter)), }; if let Err(err) = api.serve(shutdown_signal()).await { tracing::error!(?err, "server error");