Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 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
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
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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions crates/solana-solvers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ path = "src/main.rs"
axum = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
const-hex = { workspace = true }
observe = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
Expand Down
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::domain::solution::Solution::single(
0,
crate::domain::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
4 changes: 4 additions & 0 deletions crates/solana-solvers/src/domain/mod.rs
Comment thread
squadgazzz marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
//! Domain types the solver engine emits: the solution DTO the driver consumes.

pub mod order;
pub mod solution;
31 changes: 31 additions & 0 deletions crates/solana-solvers/src/domain/order.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//! 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 bytes = [0u8; 2 + 32 * 2];
bytes[..2].copy_from_slice(b"0x");
// Unwrap: the destination length always matches the input.
const_hex::encode_to_slice(self.0.as_slice(), &mut bytes[2..]).unwrap();
// Unwrap: hex output is always valid UTF-8.
f.write_str(std::str::from_utf8(&bytes).unwrap())
Comment thread
jmg-duarte marked this conversation as resolved.
Outdated
}
}

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)
}
}
276 changes: 276 additions & 0 deletions crates/solana-solvers/src/domain/solution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
//! Solution assembly: one quoted swap becomes one single-order solution in the
//! driver's `/solve` DTO.

use {
super::order::OrderUid,
crate::dex,
base64::prelude::*,
serde::Serialize,
serde_with::serde_as,
solana_sdk::{instruction::Instruction, pubkey::Pubkey},
std::collections::HashMap,
};

/// A single-order solution in the driver DTO. Trades fulfill auction orders
/// only, with no JIT.
#[serde_as]
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Solution {
pub id: u64,
/// Uniform clearing prices keyed by mint.
#[serde_as(as = "HashMap<serde_with::DisplayFromStr, _>")]
pub prices: HashMap<Pubkey, u64>,

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.

prices (and Trade::executed_amount / fee below) go on the wire as raw u64 JSON numbers, whereas the established /solve DTO in crates/solvers-dto/src/solution.rs serializes every price/amount/fee as a HexOrDecimalU256 string:

#[serde_as(as = "HashMap<_, HexOrDecimalU256>")]
pub prices: HashMap<Address, U256>,
...
#[serde_as(as = "HexOrDecimalU256")]
pub executed_amount: U256,

Two concerns:

  1. Consistency — the driver already understands the EVM wire convention (decimal/hex strings). Emitting bare numbers means the Solana /solve contract diverges from the rest of the protocol for no stated reason (the description pins the wire shape but doesn't call out the number-vs-string choice).
  2. Precision — a Solana token amount / price is a full u64, whose max (~1.8e19) exceeds 2^53. Any non-Rust JSON consumer that reads these as IEEE-754 doubles will silently corrupt large values. Strings sidestep that.

Suggest serializing these as strings to match the EVM DTO. Fix this →

Comment thread
squadgazzz marked this conversation as resolved.
Outdated
pub trades: Vec<Trade>,
pub interactions: Vec<Interaction>,
/// The address lookup tables the interactions assume, carried through so
/// the driver can build the v0 transaction around them.
#[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
pub address_lookup_tables: Vec<Pubkey>,
}

/// A fulfillment of one auction order.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Trade {
/// The order's 32-byte intent hash.
pub order_uid: OrderUid,
/// Sell-token units for sell orders, buy-token units for buy orders.
pub executed_amount: u64,
/// Fee in sell-token units. Always zero at MVP: the solver prices the
/// full quoted amounts into the clearing prices instead.
pub fee: u64,
}

/// A solver-supplied settlement interaction. Only `custom` exists: the EVM
/// DTO's other interaction kinds aren't produced on Solana, so the type
/// carries just this one.
#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Interaction {
Custom(CustomInteraction),
}

/// The aggregator's instruction carried verbatim: program ID, full account
/// metas (writable and signer flags), and the instruction data.
#[serde_as]
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomInteraction {
#[serde_as(as = "serde_with::DisplayFromStr")]
pub program_id: Pubkey,
pub accounts: Vec<AccountMeta>,
/// Base64, matching the aggregator wire encoding.
#[serde(serialize_with = "serialize_base64")]
pub instruction_data: Vec<u8>,
}

/// Account meta in the driver DTO shape.
#[serde_as]
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountMeta {
#[serde_as(as = "serde_with::DisplayFromStr")]
pub pubkey: Pubkey,
pub is_signer: bool,
pub is_writable: bool,
}

#[derive(Debug, thiserror::Error, PartialEq)]
pub enum Error {
/// Sell and buy mint coincide, so a uniform clearing-price map (one price
/// per mint) cannot represent the trade.
#[error("sell and buy mint are the same")]
SameMint,

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.

given all the effort done into removing UCPs, did we check with solvers if we should actually be adding the UCP concept back in?

@squadgazzz squadgazzz Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Dropped UCPs from the DTO. It looks like they won't be used at all.

/// A clearing price of zero would make the trade worthless downstream.
#[error("quoted amount is zero")]
ZeroAmount,
}

impl Solution {
/// Wraps one quoted swap into a single-order solution.
///
/// Clearing prices derive from the quoted amounts: the sell mint is
/// priced at the swap's output amount and the buy mint at its input
/// amount, so `executed × price` matches on both sides. The swap's
/// instructions are carried verbatim as `custom` interactions, and its
/// address lookup tables travel along so the driver can build the v0
/// transaction the instructions assume.
pub fn single(
Comment thread
squadgazzz marked this conversation as resolved.
Outdated
id: u64,
order_uid: OrderUid,
order: &dex::Order,
swap: dex::Swap,
) -> Result<Self, Error> {
if order.sell_mint == order.buy_mint {
return Err(Error::SameMint);
}
if swap.in_amount == 0 || swap.out_amount == 0 {
return Err(Error::ZeroAmount);
}
let executed_amount = match order.side {
dex::Side::Sell => swap.in_amount,
dex::Side::Buy => swap.out_amount,
};
Ok(Self {
id,
prices: HashMap::from([
(order.sell_mint, swap.out_amount),
(order.buy_mint, swap.in_amount),
]),
trades: vec![Trade {
order_uid,
executed_amount,
fee: 0,
}],
interactions: swap.instructions.iter().map(Interaction::custom).collect(),
Comment thread
squadgazzz marked this conversation as resolved.
Outdated
Comment thread
squadgazzz marked this conversation as resolved.
Outdated
address_lookup_tables: swap.address_lookup_tables,
})
}
}

impl Interaction {
fn custom(instruction: &Instruction) -> Self {
Self::Custom(CustomInteraction {
program_id: instruction.program_id,
accounts: instruction
.accounts
.iter()
.map(|meta| AccountMeta {
pubkey: meta.pubkey,
is_signer: meta.is_signer,
is_writable: meta.is_writable,
})
.collect(),
instruction_data: instruction.data.clone(),
})
}
}

fn serialize_base64<S: serde::Serializer>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&BASE64_STANDARD.encode(data))
}

#[cfg(test)]
mod tests {
use {super::*, solana_sdk::instruction::AccountMeta as SdkAccountMeta, std::str::FromStr};

fn pubkey(byte: u8) -> Pubkey {
Pubkey::new_from_array([byte; 32])
}

fn order(side: dex::Side) -> dex::Order {
dex::Order {
sell_mint: pubkey(1),
buy_mint: pubkey(2),
buy_destination: pubkey(3),
amount: 1_000,
side,
}
}

fn swap() -> dex::Swap {
dex::Swap {
in_amount: 1_000,
out_amount: 2_000,
instructions: vec![Instruction {
program_id: pubkey(9),
accounts: vec![SdkAccountMeta {
pubkey: pubkey(4),
is_signer: true,
is_writable: false,
}],
data: vec![0xde, 0xad],
}],
address_lookup_tables: vec![pubkey(7)],
}
}

#[test]
fn sell_swap_maps_to_single_order_solution() {
let order = order(dex::Side::Sell);
let solution = Solution::single(42, OrderUid([8; 32]), &order, swap()).unwrap();

assert_eq!(solution.id, 42);
// Clearing prices: sell mint priced at the output amount, buy mint at
// the input amount, so executed × price matches on both sides.
assert_eq!(solution.prices[&order.sell_mint], 2_000);
assert_eq!(solution.prices[&order.buy_mint], 1_000);
assert_eq!(solution.trades.len(), 1);
assert_eq!(solution.trades[0].order_uid, OrderUid([8; 32]));
Comment thread
squadgazzz marked this conversation as resolved.
Outdated
assert_eq!(solution.trades[0].executed_amount, 1_000);
assert_eq!(solution.trades[0].fee, 0);
assert_eq!(solution.address_lookup_tables, vec![pubkey(7)]);

// The instruction is carried verbatim, flags included.
let Interaction::Custom(custom) = &solution.interactions[0];
assert_eq!(custom.program_id, pubkey(9));
assert_eq!(custom.accounts[0].pubkey, pubkey(4));
assert!(custom.accounts[0].is_signer);
assert!(!custom.accounts[0].is_writable);
assert_eq!(custom.instruction_data, vec![0xde, 0xad]);
}

#[test]
fn buy_swap_executes_in_buy_token_units() {
let solution =
Solution::single(0, OrderUid([0; 32]), &order(dex::Side::Buy), swap()).unwrap();
assert_eq!(solution.trades[0].executed_amount, 2_000);
}

#[test]
fn same_mint_order_is_rejected() {
let mut order = order(dex::Side::Sell);
order.buy_mint = order.sell_mint;
assert_eq!(
Solution::single(0, OrderUid([0; 32]), &order, swap()).unwrap_err(),
Error::SameMint
);
}

#[test]
fn zero_quoted_amount_is_rejected() {
let mut swap = swap();
swap.out_amount = 0;
assert_eq!(
Solution::single(0, OrderUid([0; 32]), &order(dex::Side::Sell), swap).unwrap_err(),
Error::ZeroAmount
);
}

#[test]
fn wire_format_is_stable() {
let solution =
Solution::single(1, OrderUid([8; 32]), &order(dex::Side::Sell), swap()).unwrap();
let json = serde_json::to_value(&solution).unwrap();

assert_eq!(
json["prices"][pubkey(1).to_string()],
serde_json::json!(2_000)
);
assert_eq!(
json["trades"][0]["orderUid"],
format!("0x{}", "08".repeat(32))
);
assert_eq!(json["trades"][0]["executedAmount"], 1_000);
assert_eq!(json["interactions"][0]["kind"], "custom");
assert_eq!(json["interactions"][0]["programId"], pubkey(9).to_string());
assert_eq!(
json["interactions"][0]["instructionData"],
BASE64_STANDARD.encode([0xde, 0xad])
);
assert!(
json["interactions"][0]["accounts"][0]["isSigner"]
.as_bool()
.unwrap()
);
assert_eq!(json["addressLookupTables"][0], pubkey(7).to_string());
}

#[test]
fn from_str_roundtrip_for_wire_keys() {
// Pubkeys serialize as base58 and parse back.
let key = pubkey(5);
assert_eq!(Pubkey::from_str(&key.to_string()).unwrap(), key);
}
}
1 change: 1 addition & 0 deletions crates/solana-solvers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod api;
mod cli;
pub mod config;
pub mod dex;
pub mod domain;
mod run;

pub use run::start;
Loading