Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
01885b2
feat(solana-solvers): PR 1 crate skeleton, config, /solve scaffold
squadgazzz Jul 16, 2026
78cc58a
chore(solana-solvers): tombi-format Cargo.toml
squadgazzz Jul 16, 2026
5ef277f
refactor(solana-solvers): align CLI/logging/shutdown with crates/solv…
squadgazzz Jul 16, 2026
6dccbfb
refactor(solana-solvers): drop unused deps, type slippage as bps (add…
squadgazzz Jul 16, 2026
a061fa2
feat(solana-solvers): Jupiter /swap-instructions adapter + provider b…
squadgazzz Jul 17, 2026
aa98a06
feat(solana-solvers): direct-delivery to order receiver via destinati…
squadgazzz Jul 17, 2026
dbb20c9
refactor(solana-solvers): drop single-variant Dex enum, call the adap…
squadgazzz Jul 17, 2026
a29cc0f
refactor(solana-solvers): keep Dex enum ahead of the baseline engine
squadgazzz Jul 17, 2026
e28027c
refactor(solana-solvers): send swap output to the settlement buffer, …
squadgazzz Jul 17, 2026
d9e5c36
refactor(solana-solvers): switch to Jupiter Swap V2 /build (single ca…
squadgazzz Jul 17, 2026
78a8bca
docs(solana-solvers): describe current behavior in comments, drop fro…
squadgazzz Jul 17, 2026
03e36aa
docs(solana-solvers): describe current behavior in comments, drop fro…
squadgazzz Jul 17, 2026
1c72157
docs(solana-solvers): drop lite-api mentions
squadgazzz Jul 17, 2026
cfa1dc5
docs(solana-solvers): drop lite-api mentions
squadgazzz Jul 17, 2026
cc5928a
docs(solana-solvers): trim comments to the relevant fact
squadgazzz Jul 17, 2026
6e3bae1
refactor(solana-solvers): route sells through v1 /quote + /swap-instr…
squadgazzz Jul 17, 2026
3ca086e
Merge remote-tracking branch 'origin/solana-solvers/PR1-skeleton' int…
squadgazzz Jul 17, 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
20 changes: 20 additions & 0 deletions Cargo.lock

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

33 changes: 33 additions & 0 deletions crates/solana-solvers/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[package]
name = "solana-solvers"
version = "0.1.0"
edition = "2024"
description = "Solana solver engines for CoW Protocol (Jupiter dex-wrapper)"
license = "GPL-3.0-or-later"

[lib]
name = "solana_solvers"
path = "src/lib.rs"

[[bin]]
name = "solana-solvers"
path = "src/main.rs"

[dependencies]
axum = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
observe = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
solana-sdk = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "signal"] }
toml = { workspace = true }
tower-http = { workspace = true, features = ["limit"] }
tracing = { workspace = true }
url = { workspace = true, features = ["serde"] }

[lints]
workspace = true
11 changes: 11 additions & 0 deletions crates/solana-solvers/config/example.jupiter.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Example Jupiter solver configuration.
# Run with: solana-solvers jupiter --config <this file>

[dex]
# Jupiter swap API base URL: api.jup.ag, or a Triton-hosted endpoint.
endpoint = "https://api.jup.ag"
# API key from the Jupiter developer portal (or Triton). Works without one but
# heavily rate-limited, set it for production.
api-key = "your-jupiter-api-key"
# Slippage tolerance in basis points, sent to Jupiter as slippageBps. 50 = 0.5%.
slippage-bps = 50
55 changes: 55 additions & 0 deletions crates/solana-solvers/src/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! 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.

use {
crate::config::Config,
axum::{
Json,
Router,
extract::State,
routing::{get, post},
},
serde_json::{Value, json},
std::{future::Future, net::SocketAddr, sync::Arc},
tower_http::limit::RequestBodyLimitLayer,
};

const REQUEST_BODY_LIMIT: usize = 10 * 1024 * 1024;

pub struct Api {
pub addr: SocketAddr,
pub config: Config,
}

impl Api {
/// Bind and serve until `shutdown` resolves.
pub async fn serve(
self,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> std::io::Result<()> {
let app = Router::new()
.route("/healthz", get(healthz))
.route("/solve", post(solve))
.with_state(Arc::new(self.config))
.layer(RequestBodyLimitLayer::new(REQUEST_BODY_LIMIT))
.layer(axum::extract::DefaultBodyLimit::disable());

let listener = tokio::net::TcpListener::bind(self.addr).await?;
tracing::info!(addr = %self.addr, "solana-solvers listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await
}
}

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": [] }))
}
38 changes: 38 additions & 0 deletions crates/solana-solvers/src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! CLI arguments for the `solana-solvers` binary.

use {
clap::{Parser, Subcommand},
std::{net::SocketAddr, path::PathBuf},
};

/// Run a Solana solver engine.
#[derive(Parser, Debug)]
#[command(version)]
pub struct Args {
/// The log filter.
#[arg(long, env, default_value = "warn,solana_solvers=debug")]
pub log: String,

/// Whether to use JSON format for the logs.
#[clap(long, env, default_value = "false")]
pub use_json_logs: bool,

/// The socket address to bind to.
#[arg(long, env, default_value = "127.0.0.1:7900")]
pub addr: SocketAddr,

#[command(subcommand)]
pub command: Command,
}

/// The solver engine to run. `config` is a path to a TOML config file.
#[derive(Subcommand, Debug)]
#[clap(rename_all = "lowercase")]
pub enum Command {
/// Wrap Jupiter's quote API into single-order solutions.
Jupiter {
#[clap(long, env)]
config: PathBuf,
},
// TODO: add a baseline engine subcommand.
}
65 changes: 65 additions & 0 deletions crates/solana-solvers/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! Solver-engine configuration.

use {serde::Deserialize, std::path::Path, url::Url};

/// Jupiter solver configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Config {
pub dex: JupiterConfig,
}

/// The `[dex]` table for the Jupiter backend.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct JupiterConfig {
/// Base URL of the Jupiter swap API: `api.jup.ag`, or a Triton-hosted
/// endpoint.
pub endpoint: Url,

/// API key from the Jupiter developer portal (or Triton). Requests work
/// without one but are heavily rate-limited, so set it for production.
#[serde(default)]
pub api_key: Option<String>,

/// Slippage tolerance in basis points, sent to Jupiter as `slippageBps`.
/// 50 = 0.5%.
pub slippage_bps: u16,
}

/// Load and parse the TOML config file.
///
/// # Panics
///
/// Panics on I/O or parse errors: a bad config is a startup failure.
pub async fn load(path: &Path) -> Config {
let text = tokio::fs::read_to_string(path)
.await
.unwrap_or_else(|err| panic!("read config {}: {err}", path.display()));
toml::from_str(&text).unwrap_or_else(|err| panic!("parse config {}: {err}", path.display()))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_example_config() {
let config: Config =
toml::from_str(include_str!("../config/example.jupiter.toml")).unwrap();
assert_eq!(config.dex.endpoint.as_str(), "https://api.jup.ag/");
assert_eq!(config.dex.slippage_bps, 50);
assert!(config.dex.api_key.is_some());
}

#[test]
fn rejects_unknown_keys() {
let toml = r#"
[dex]
endpoint = "https://api.jup.ag"
slippage-bps = 50
bogus = true
"#;
assert!(toml::from_str::<Config>(toml).is_err());
}
}
99 changes: 99 additions & 0 deletions crates/solana-solvers/src/dex/jupiter/dto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! DTO for Jupiter's `/swap-instructions` response, converted to Solana
//! instructions. Field names follow Jupiter's camelCase JSON.

use {
super::Error,
crate::dex::Swap,
base64::prelude::*,
serde::Deserialize,
solana_sdk::{
instruction::{AccountMeta, Instruction},
pubkey::Pubkey,
},
std::str::FromStr,
};

/// The parts of the `/swap-instructions` response we need to build a [`Swap`].
/// Amounts come from the `/quote` response.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SwapInstructionsResponse {
#[serde(default)]
setup_instructions: Vec<JupInstruction>,
swap_instruction: JupInstruction,
#[serde(default)]
cleanup_instruction: Option<JupInstruction>,
#[serde(default)]
address_lookup_table_addresses: Vec<String>,
}

impl SwapInstructionsResponse {
/// Flatten into execution order (setup, swap, cleanup) and resolve the
/// lookup-table addresses.
pub fn into_swap(self, in_amount: u64, out_amount: u64) -> Result<Swap, Error> {
let mut instructions = Vec::with_capacity(self.setup_instructions.len() + 2);
for instruction in self.setup_instructions {
instructions.push(instruction.into_instruction()?);
}
instructions.push(self.swap_instruction.into_instruction()?);
if let Some(instruction) = self.cleanup_instruction {
instructions.push(instruction.into_instruction()?);
}
let address_lookup_tables = self
.address_lookup_table_addresses
.iter()
.map(|address| Pubkey::from_str(address).map_err(|_| Error::BadResponse))
.collect::<Result<_, _>>()?;
Ok(Swap {
in_amount,
out_amount,
instructions,
address_lookup_tables,
})
}
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct JupInstruction {
program_id: String,
accounts: Vec<JupAccount>,
data: String,
}

impl JupInstruction {
fn into_instruction(self) -> Result<Instruction, Error> {
let program_id = Pubkey::from_str(&self.program_id).map_err(|_| Error::BadResponse)?;
let accounts = self
.accounts
.into_iter()
.map(JupAccount::into_meta)
.collect::<Result<_, _>>()?;
let data = BASE64_STANDARD
.decode(&self.data)
.map_err(|_| Error::BadResponse)?;
Ok(Instruction {
program_id,
accounts,
data,
})
}
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct JupAccount {
pubkey: String,
is_signer: bool,
is_writable: bool,
}

impl JupAccount {
fn into_meta(self) -> Result<AccountMeta, Error> {
Ok(AccountMeta {
pubkey: Pubkey::from_str(&self.pubkey).map_err(|_| Error::BadResponse)?,
is_signer: self.is_signer,
is_writable: self.is_writable,
})
}
}
Loading
Loading