Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c05ec9a
feat(solana-solvers): PR 3 solution assembly (custom interactions, pr…
squadgazzz Jul 21, 2026
822a212
Drop spec references from code comments
squadgazzz Jul 21, 2026
2a43350
Trim solution module doc to the three deliberate absences
squadgazzz Jul 21, 2026
efe1c02
Introduce OrderUid newtype matching the indexer convention
squadgazzz Jul 21, 2026
5f18acf
Move absence notes to where the decisions happen, drop tautological t…
squadgazzz Jul 21, 2026
c5104e6
Assemble a solution end to end in the live Jupiter sell test
squadgazzz Jul 21, 2026
1df3840
Validate the assembled solution in the live test instead of printing it
squadgazzz Jul 21, 2026
3aa1656
refactor(solana-solvers): match EVM OrderUid hex Display, plainer int…
squadgazzz Jul 21, 2026
72452aa
feat(solana-solvers): add optional cu_estimate to the solution DTO, u…
squadgazzz Jul 21, 2026
d25f8ac
refactor(solana-solvers): flatten interactions to Vec<Instruction> ma…
squadgazzz Jul 21, 2026
832bd62
refactor(solana-solvers): serialize solution amounts as decimal strings
squadgazzz Jul 21, 2026
2f430df
refactor(solana-solvers): trim solution doc comments, drop redundant …
squadgazzz Jul 21, 2026
7385b72
docs(solana-solvers): describe shared solution DTO by contract, not s…
squadgazzz Jul 21, 2026
9887f1b
refactor(solana-solvers): move swap instructions into the solution in…
squadgazzz Jul 21, 2026
030aca4
refactor(solana-solvers): use const_hex::Buffer for OrderUid, tidy so…
squadgazzz Jul 22, 2026
18ba577
refactor(solana-solvers): drop clearing prices from the solve DTO
squadgazzz Jul 22, 2026
72867ba
feat(solana-solvers): solve loop, order handling, engine wiring
squadgazzz Jul 21, 2026
106bc86
refactor(solana-solvers): move solve DTOs to a dto module and rename …
squadgazzz Jul 24, 2026
7febcb7
refactor(solana-solvers): drop the unused fee field from the solve DTO
squadgazzz Jul 24, 2026
9eddd81
Merge pr3 into pr4, split wire DTOs (dto) from the solve engine (domain)
squadgazzz Jul 24, 2026
0190efc
test(solana-solvers): drop redundant jupiter_live_solve, adapter live…
squadgazzz Jul 24, 2026
941d38f
Document where the swap route spends its input from
squadgazzz Aug 4, 2026
44b51da
Merge remote-tracking branch 'origin/main' into solana-solvers/pr4-so…
squadgazzz Aug 4, 2026
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
2 changes: 2 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions crates/solana-solvers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ path = "src/main.rs"
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"] }
Expand Down
17 changes: 8 additions & 9 deletions crates/solana-solvers/src/api.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,7 +19,7 @@ const REQUEST_BODY_LIMIT: usize = 10 * 1024 * 1024;

pub struct Api {
pub addr: SocketAddr,
pub config: Config,
pub dex: Arc<Dex>,
}

impl Api {
Expand All @@ -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());

Expand All @@ -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<Arc<Config>>, Json(_auction): Json<Value>) -> Json<Value> {
Json(json!({ "solutions": [] }))
/// Quote every order in the auction and return the single-order solutions.
async fn solve(State(dex): State<Arc<Dex>>, Json(auction): Json<Auction>) -> Json<Value> {
let solutions = solver::solve(dex.as_ref(), &auction).await;
Json(json!({ "solutions": solutions }))
}
5 changes: 5 additions & 0 deletions crates/solana-solvers/src/dex/jupiter/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ impl<'a> SwapInstructionsRequest<'a> {

/// The parts of the `/swap-instructions` response we need to build a [`Swap`].
/// Amounts come from the `/quote` response.
///
/// The response's `computeBudgetInstructions` are deliberately not read: the
/// driver emits its own ComputeBudget instructions sized by simulating the
/// full settlement transaction, and a transaction with two of them is
/// rejected by the runtime.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SwapInstructionsResponse {
Expand Down
18 changes: 17 additions & 1 deletion crates/solana-solvers/src/dex/jupiter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ impl Jupiter {
.append_pair("outputMint", &order.buy_mint.to_string())
.append_pair("amount", &order.amount.to_string())
.append_pair("swapMode", swap_mode.as_str())
// Jupiter bakes the resulting bounds into the returned instruction
// data; nothing downstream re-applies slippage.
.append_pair("slippageBps", &self.slippage_bps.to_string());
self.send(self.with_key(self.client.get(url))).await
}
Expand Down Expand Up @@ -203,13 +205,27 @@ mod tests {
let jupiter = Jupiter::new(&config(false)).unwrap();
// Any valid pubkey works for building instructions, the swap only runs
// for real once the driver supplies its settlement signer.
let sell = order(Side::Sell);
let swap = jupiter
.swap(&order(Side::Sell), &Pubkey::from_str(WSOL).unwrap())
.swap(&sell, &Pubkey::from_str(WSOL).unwrap())
.await
.unwrap();
assert_eq!(swap.in_amount, 1_000_000);
assert!(swap.out_amount > 0);
assert!(!swap.instructions.is_empty());

// End to end: the live swap assembles into a valid solution that
// serializes.
let solution = crate::dto::solution::Solution::new(
0,
crate::dto::order::OrderUid([1; 32]),
&sell,
swap,
)
.unwrap();
assert_eq!(solution.trades.len(), 1);
assert!(!solution.interactions.is_empty());
serde_json::to_string(&solution).unwrap();
}

/// Live Jupiter API. Needs network. Keyless works, set `JUPITER_API_KEY`
Expand Down
3 changes: 2 additions & 1 deletion crates/solana-solvers/src/dex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions crates/solana-solvers/src/domain/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
//! Domain logic: the solve loop that quotes each auction order and assembles
//! the solutions the driver consumes.

pub mod solver;
124 changes: 124 additions & 0 deletions crates/solana-solvers/src/domain/solver/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Output = Result<dex::Swap, dex::jupiter::Error>> + Send;
}

impl Quote for Dex {
fn quote(
&self,
order: &dex::Order,
taker: &Pubkey,
) -> impl Future<Output = Result<dex::Swap, dex::jupiter::Error>> + 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<Q: Quote>(quoter: &Q, auction: &Auction) -> Vec<Solution> {
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<Output = Result<dex::Swap, dex::jupiter::Error>> + 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());
}
}
59 changes: 59 additions & 0 deletions crates/solana-solvers/src/dto/auction.rs
Original file line number Diff line number Diff line change
@@ -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<Order>,
}

/// 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,
}
}
}
6 changes: 6 additions & 0 deletions crates/solana-solvers/src/dto/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//! 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;
37 changes: 37 additions & 0 deletions crates/solana-solvers/src/dto/order.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//! CoW Protocol order identifier.

use std::fmt;

/// A 32-byte CoW Protocol order identifier, equal to `hash(intent)`,
/// serialized as a `0x`-prefixed hex string on the wire.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrderUid(pub [u8; 32]);

impl fmt::Display for OrderUid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut buffer = const_hex::Buffer::<32, true>::new();
f.write_str(buffer.format(&self.0))
}
}

impl fmt::Debug for OrderUid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self}")
}
}

impl serde::Serialize for OrderUid {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}

impl std::str::FromStr for OrderUid {
type Err = const_hex::FromHexError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut bytes = [0u8; 32];
const_hex::decode_to_slice(s.strip_prefix("0x").unwrap_or(s), &mut bytes)?;
Ok(Self(bytes))
}
}
Loading
Loading