diff --git a/Cargo.lock b/Cargo.lock index 056e382b49..bf2011a975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4272,7 +4272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -8835,6 +8835,7 @@ dependencies = [ "serde", "serde_with", "solana-sdk", + "url", ] [[package]] @@ -9714,8 +9715,10 @@ name = "solana-driver" version = "0.1.0" dependencies = [ "axum", + "chrono", "clap", "configs", + "const-hex", "cow-solana-rpc", "futures", "humantime-serde", @@ -9723,7 +9726,11 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde-ext", + "serde_json", + "serde_with", "solana-sdk", + "solana-solvers", + "thiserror 1.0.69", "tikv-jemallocator", "tokio", "tokio-util", @@ -10802,6 +10809,7 @@ version = "0.1.0" dependencies = [ "axum", "base64 0.22.1", + "chrono", "clap", "const-hex", "futures", @@ -13119,7 +13127,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d1cb00b5b6..8823871bf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ bytes-hex = { path = "crates/bytes-hex" } cached = { version = "0.49.3", default-features = false } chain = { path = "crates/chain" } chain-types = { path = "crates/chain-types" } -chrono = { version = "0.4.38", default-features = false } +chrono = { version = "0.4.38", default-features = false, features = ["serde"] } clap = { version = "4.5.6", features = ["derive", "env"] } configs = { path = "crates/configs/" } console-subscriber = "0.5.0" @@ -120,7 +120,7 @@ serde = { version = "1.0.203", features = ["derive"] } serde-ext = { path = "crates/serde-ext" } serde_json = { version = "1.0.117", features = ["raw_value", "unbounded_depth"] } serde_stacker = "0.1.14" -serde_with = "3.8.1" +serde_with = { version = "3.8.1", features = ["base64"] } sha2 = "0.10" shared = { path = "crates/shared" } signature-validator = { path = "crates/signature-validator" } diff --git a/crates/serde-ext/Cargo.toml b/crates/serde-ext/Cargo.toml index 5e930a908f..8f321e0338 100644 --- a/crates/serde-ext/Cargo.toml +++ b/crates/serde-ext/Cargo.toml @@ -11,3 +11,4 @@ const-hex = { workspace = true } serde = { workspace = true } serde_with = { workspace = true } solana-sdk = { workspace = true } +url = { workspace = true, features = ["serde"] } diff --git a/crates/serde-ext/src/lib.rs b/crates/serde-ext/src/lib.rs index 6c2ac6ada7..3f72e3439a 100644 --- a/crates/serde-ext/src/lib.rs +++ b/crates/serde-ext/src/lib.rs @@ -4,10 +4,12 @@ mod hex; mod nonempty; mod pubkey; mod u256; +mod url; pub use self::{ hex::Hex, nonempty::deserialize_nonempty_vec, pubkey::{deserialize_optional_solana_pubkey_b58, deserialize_solana_pubkey_b58}, u256::U256, + url::deserialize_url_with_trailing_slash, }; diff --git a/crates/serde-ext/src/url.rs b/crates/serde-ext/src/url.rs new file mode 100644 index 0000000000..0b88b8bd09 --- /dev/null +++ b/crates/serde-ext/src/url.rs @@ -0,0 +1,42 @@ +use { + serde::{Deserialize, Deserializer}, + url::Url, +}; + +/// Deserialize a URL and make sure its path ends with a slash. +pub fn deserialize_url_with_trailing_slash<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let mut url = Url::deserialize(deserializer)?; + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + Ok(url) +} + +#[cfg(test)] +mod tests { + use { + super::*, + serde::de::value::{Error, StrDeserializer}, + }; + + #[test] + fn appends_trailing_slash_when_missing() { + let de = StrDeserializer::::new("http://localhost:8001/api"); + let url: Url = deserialize_url_with_trailing_slash(de).unwrap(); + assert_eq!(url.as_str(), "http://localhost:8001/api/"); + assert_eq!( + url.join("solve").unwrap().as_str(), + "http://localhost:8001/api/solve" + ); + } + + #[test] + fn keeps_existing_trailing_slash() { + let de = StrDeserializer::::new("http://localhost:8001/api/"); + let url: Url = deserialize_url_with_trailing_slash(de).unwrap(); + assert_eq!(url.as_str(), "http://localhost:8001/api/"); + } +} diff --git a/crates/solana-driver/Cargo.toml b/crates/solana-driver/Cargo.toml index beed3ad2c7..f7a7990f9a 100644 --- a/crates/solana-driver/Cargo.toml +++ b/crates/solana-driver/Cargo.toml @@ -17,8 +17,10 @@ path = "src/main.rs" [dependencies] axum = { workspace = true } +chrono = { workspace = true, default-features = false, features = ["clock"] } clap = { workspace = true } configs = { workspace = true } +const-hex = { workspace = true } cow-solana-rpc = { workspace = true } futures = { workspace = true } humantime-serde = { workspace = true } @@ -26,7 +28,10 @@ observe = { workspace = true } reqwest = { workspace = true, features = ["json"] } serde = { workspace = true } serde-ext = { workspace = true } +serde_json = { workspace = true } +serde_with = { workspace = true } solana-sdk = { workspace = true } +thiserror = { workspace = true } tikv-jemallocator = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } tokio-util = { workspace = true } @@ -38,6 +43,8 @@ url = { workspace = true, features = ["serde"] } [dev-dependencies] cow-solana-rpc = { workspace = true, features = ["test-util"] } +solana-solvers = { path = "../solana-solvers" } +tokio = { workspace = true, features = ["macros", "rt"] } [features] default = [] diff --git a/crates/solana-driver/example.toml b/crates/solana-driver/example.toml index 1e2f3da32c..ca0f55c40a 100644 --- a/crates/solana-driver/example.toml +++ b/crates/solana-driver/example.toml @@ -2,7 +2,7 @@ [chain] # Placeholder — replace with the CoW Settlement program id. -settlement-program-id = "11111111111111111111111111111111" +settlement-program-id = "MooohhPEAAHwAwEozL7JPEmnDvaahuUpccYN4Yb8ccK" [rpc] endpoint = "https://api.mainnet-beta.solana.com" @@ -19,4 +19,5 @@ filter = "info,solana_driver=debug" [[solvers]] name = "baseline" endpoint = "http://localhost:8001" +account = "9VXC6LH9eXMBpXLQnxMYAGkjs59Zon2ACciJwQ6iMzNB" max-in-flight = 1 diff --git a/crates/solana-driver/src/domain/auction.rs b/crates/solana-driver/src/domain/auction.rs new file mode 100644 index 0000000000..f799c96628 --- /dev/null +++ b/crates/solana-driver/src/domain/auction.rs @@ -0,0 +1,33 @@ +//! Domain model of an auction the driver asks solver engines to fill. + +use {super::order_uid::OrderUid, serde::Serialize, solana_sdk::pubkey::Pubkey}; + +/// A collection of orders the driver wants solvers to fill. +#[derive(Clone, Debug)] +pub struct Auction { + pub id: i64, + pub orders: Vec, + /// Absolute deadline by which solver engines must return solutions. The + /// driver derives each request's timeout as the time remaining until this + /// instant, and skips the request entirely if the deadline has passed. + pub deadline: chrono::DateTime, +} + +/// One order available for solvers to fill. +#[derive(Clone, Debug)] +pub struct Order { + pub uid: OrderUid, + pub sell_mint: Pubkey, + pub buy_mint: Pubkey, + /// Sell amount for sells, buy amount for buys. + pub amount: u64, + pub side: Side, +} + +/// Direction of the trade. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Side { + Sell, + Buy, +} diff --git a/crates/solana-driver/src/domain/mod.rs b/crates/solana-driver/src/domain/mod.rs new file mode 100644 index 0000000000..4b7c51ed04 --- /dev/null +++ b/crates/solana-driver/src/domain/mod.rs @@ -0,0 +1,13 @@ +//! Domain model of the Solana driver. +//! +//! These types describe the concepts the driver works with — auctions and +//! solutions — independent of any wire format or RPC representation. + +pub mod auction; +pub mod order_uid; +pub mod solution; + +pub use self::{ + auction::{Auction, Order, Side}, + solution::{Solution, Trade}, +}; diff --git a/crates/solana-driver/src/domain/order_uid.rs b/crates/solana-driver/src/domain/order_uid.rs new file mode 100644 index 0000000000..923de37c6e --- /dev/null +++ b/crates/solana-driver/src/domain/order_uid.rs @@ -0,0 +1,44 @@ +//! CoW Protocol order identifier. + +use std::{fmt, str::FromStr}; + +/// 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 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)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn order_uid_roundtrip() { + let uid = OrderUid([0xab; 32]); + let text = uid.to_string(); + assert_eq!(text, format!("0x{}", "ab".repeat(32))); + assert_eq!(OrderUid::from_str(&text).unwrap(), uid); + } +} diff --git a/crates/solana-driver/src/domain/solution.rs b/crates/solana-driver/src/domain/solution.rs new file mode 100644 index 0000000000..4835377180 --- /dev/null +++ b/crates/solana-driver/src/domain/solution.rs @@ -0,0 +1,29 @@ +//! Domain model of a solver engine's solution. + +use { + super::order_uid::OrderUid, + solana_sdk::{instruction::Instruction, pubkey::Pubkey}, +}; + +/// A single solver engine's response to one auction. +#[derive(Clone, Debug)] +pub struct Solution { + pub id: u64, + /// The on-chain identity of the solver that produced this solution. + pub solver: Pubkey, + pub trades: Vec, + /// Solana instructions to execute as part of the settlement. + pub interactions: Vec, + /// Address lookup tables the interactions assume. + pub address_lookup_tables: Vec, + /// Optional solver estimate of total settlement compute units. + pub cu_estimate: Option, +} + +/// A fulfillment of one auction order. +#[derive(Clone, Debug)] +pub struct Trade { + pub order_uid: OrderUid, + /// Sell-token units for sell orders, buy-token units for buy orders. + pub executed_amount: u64, +} diff --git a/crates/solana-driver/src/infra/api/mod.rs b/crates/solana-driver/src/infra/api/mod.rs index 6c86bfd49c..bb2a938d45 100644 --- a/crates/solana-driver/src/infra/api/mod.rs +++ b/crates/solana-driver/src/infra/api/mod.rs @@ -1,6 +1,7 @@ //! HTTP API server. use { + crate::infra::solver, axum::{ Router, extract::DefaultBodyLimit, @@ -22,6 +23,8 @@ pub struct Api { pub addr: SocketAddr, /// The shared Solana RPC client. pub rpc: SolanaRPC, + /// Configured solver engines. + pub solvers: Vec, } impl Api { @@ -49,7 +52,7 @@ impl Api { .layer(TraceLayer::new_for_http().make_span_with(make_span)) .map_request(record_trace_id); - let state = State::new(self.rpc); + let state = State::new(self.rpc, self.solvers); let app = Router::new() .route("/healthz", get(routes::healthz)) @@ -69,22 +72,21 @@ impl Api { } /// Shared state available to all route handlers. -/// -/// The inner field is not yet read by any handler (the `/solve` and `/settle` -/// handlers are stubs), so `#[expect(dead_code)]` suppresses the unused-field -/// warning until shared state is added. #[derive(Clone)] #[expect(dead_code)] pub struct State(Arc); impl State { - fn new(rpc: SolanaRPC) -> Self { - Self(Arc::new(Inner { rpc })) + /// Build the shared state the handlers operate on. + fn new(rpc: SolanaRPC, solvers: Vec) -> Self { + Self(Arc::new(Inner { rpc, solvers })) } } +#[expect(dead_code)] struct Inner { /// The shared Solana RPC client. - #[expect(dead_code)] rpc: SolanaRPC, + /// Configured solver engines. + solvers: Vec, } diff --git a/crates/solana-driver/src/infra/config.rs b/crates/solana-driver/src/infra/config.rs index 53b86994c1..256fd5e2d5 100644 --- a/crates/solana-driver/src/infra/config.rs +++ b/crates/solana-driver/src/infra/config.rs @@ -3,7 +3,11 @@ use { configs::shared::LoggingConfig, serde::Deserialize, - serde_ext::{deserialize_nonempty_vec, deserialize_solana_pubkey_b58}, + serde_ext::{ + deserialize_nonempty_vec, + deserialize_solana_pubkey_b58, + deserialize_url_with_trailing_slash, + }, solana_sdk::pubkey::Pubkey, std::{net::SocketAddr, num::NonZero, path::Path, time::Duration}, tokio::fs, @@ -98,7 +102,12 @@ pub struct Solver { /// metrics. pub name: String, /// HTTP endpoint of the solver engine API. + #[serde(deserialize_with = "deserialize_url_with_trailing_slash")] pub endpoint: url::Url, + /// The solver's on-chain identity. Reported on every `domain::Solution` + /// produced by this engine. + #[serde(deserialize_with = "deserialize_solana_pubkey_b58")] + pub account: Pubkey, /// Maximum number of concurrent solve requests kept in flight per solver. pub max_in_flight: NonZero, } @@ -119,11 +128,37 @@ mod tests { assert_eq!(config.solvers.len(), 1); assert_eq!(config.solvers[0].name, "baseline"); assert_eq!(config.solvers[0].max_in_flight.get(), 1); + assert_eq!( + config.chain.settlement_program_id, + "MooohhPEAAHwAwEozL7JPEmnDvaahuUpccYN4Yb8ccK" + .parse() + .unwrap() + ); + assert_eq!( + config.solvers[0].account, + "9VXC6LH9eXMBpXLQnxMYAGkjs59Zon2ACciJwQ6iMzNB" + .parse() + .unwrap() + ); assert_eq!(config.logging.filter, "info,solana_driver=debug"); assert_eq!(config.logging.stderr_threshold, None); assert!(!config.logging.use_json); } + #[test] + fn solver_config_parses() { + let solver_config = r#" + name = "baseline" + endpoint = "http://localhost:8001" + account = "11111111111111111111111111111111" + max-in-flight = 1 + "#; + let solver: Solver = toml::de::from_str(solver_config).unwrap(); + assert_eq!(solver.name, "baseline"); + assert_eq!(solver.account, Pubkey::default()); + assert_eq!(solver.max_in_flight.get(), 1); + } + #[test] fn zero_max_in_flight_rejected() { let solver_config = r#" diff --git a/crates/solana-driver/src/infra/mod.rs b/crates/solana-driver/src/infra/mod.rs index 6500efbec6..7c95c14c3a 100644 --- a/crates/solana-driver/src/infra/mod.rs +++ b/crates/solana-driver/src/infra/mod.rs @@ -1,8 +1,9 @@ //! Infrastructure layer: concrete implementations of the driver's external -//! dependencies (configuration, RPC, HTTP API, observability). +//! dependencies (configuration, RPC, HTTP API, observability, solver engines). pub mod api; pub mod config; pub mod observe; +pub mod solver; pub use self::api::Api; diff --git a/crates/solana-driver/src/infra/solver/dto/auction.rs b/crates/solana-driver/src/infra/solver/dto/auction.rs new file mode 100644 index 0000000000..f1287dce13 --- /dev/null +++ b/crates/solana-driver/src/infra/solver/dto/auction.rs @@ -0,0 +1,137 @@ +//! Outbound `/solve` request: the auction the driver posts to a solver engine. +//! +//! The wire format matches `solana-solvers/src/dto/auction.rs`. + +use { + crate::{ + domain::{self, Side, order_uid::OrderUid}, + util::associated_token_address, + }, + serde::Serialize, + serde_with::serde_as, + solana_sdk::pubkey::Pubkey, +}; + +/// The auction the driver posts to `/solve`. +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Auction { + pub id: i64, + /// Settlement signer the swap instructions are built for. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub taker: Pubkey, + pub orders: Vec, + /// Absolute deadline by which solutions must be returned. + pub deadline: chrono::DateTime, +} + +/// One order to quote. +#[serde_as] +#[derive(Debug, Serialize)] +#[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, + #[serde_as(as = "serde_with::DisplayFromStr")] + pub buy_destination: Pubkey, + /// Sell amount for a sell, buy amount for a buy + /// Represented as a quoted decimal string instead of a JSON number. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub amount: u64, + pub side: Side, +} + +impl Order { + /// Build the wire order from a domain order and the settlement taker. + /// + /// The `taker` is the solver that signs the settlement transaction; the + /// driver derives `buy_destination` as its ATA for the buy mint on the same + /// premise as the sell token. + fn from_order_and_taker(order: &domain::Order, taker: Pubkey) -> Self { + Self { + uid: order.uid, + sell_mint: order.sell_mint, + buy_mint: order.buy_mint, + buy_destination: associated_token_address(&taker, &order.buy_mint), + amount: order.amount, + side: order.side, + } + } +} + +impl Auction { + /// Build the wire auction from the domain auction. + /// + /// The `taker` is a concept borrowed from the solana-solvers API: the + /// solver that signs the settlement transaction. Under the current API the + /// sell token is the taker's ATA, and the driver derives `buy_destination` + /// as its buy-side counterpart on the same premise. + pub fn new(auction: &domain::Auction, taker: Pubkey) -> Self { + Self { + id: auction.id, + taker, + orders: auction + .orders + .iter() + .map(|order| Order::from_order_and_taker(order, taker)) + .collect(), + deadline: auction.deadline, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::{domain::Side, util}, + serde_json::json, + }; + + fn pubkey(byte: u8) -> Pubkey { + Pubkey::new_from_array([byte; 32]) + } + + /// Pins the outbound `/solve` request shape against the literal the + /// `solana-solvers` `Auction` deserializes. + #[test] + fn wire_format_is_stable() { + let json = json!({ + "id": 1, + "taker": pubkey(3).to_string(), + "orders": [{ + "uid": format!("0x{}", "08".repeat(32)), + "sellMint": pubkey(1).to_string(), + "buyMint": pubkey(2).to_string(), + "buyDestination": util::associated_token_address(&pubkey(3), &pubkey(2)).to_string(), + "amount": "1000", + "side": "sell", + }], + "deadline": "2026-01-01T00:00:00Z", + }); + + let expected = Auction { + id: 1, + taker: pubkey(3), + orders: vec![Order { + uid: OrderUid([8; 32]), + sell_mint: pubkey(1), + buy_mint: pubkey(2), + buy_destination: util::associated_token_address(&pubkey(3), &pubkey(2)), + amount: 1_000, + side: Side::Sell, + }], + deadline: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + }; + + let actual = serde_json::to_value(&expected).unwrap(); + assert_eq!(actual, json); + } +} diff --git a/crates/solana-driver/src/infra/solver/dto/mod.rs b/crates/solana-driver/src/infra/solver/dto/mod.rs new file mode 100644 index 0000000000..2229a16dc9 --- /dev/null +++ b/crates/solana-driver/src/infra/solver/dto/mod.rs @@ -0,0 +1,15 @@ +//! Wire DTOs for the driver <-> solver-engine HTTP boundary. +//! +//! These structs are the driver's own mirror of the `solana-solvers` +//! `dto/{auction,solution}.rs` types; they are deliberately not shared with the +//! solver crate so the wire format can evolve on one side at a time. Serde +//! tests in each module pin the JSON shape against the literals the solver +//! crate tests assert. +//! +//! Eventually, we could extract a shared `dto` crate (like the EVM driver does +//! with `solvers-dto`) to keep the API consistent without needing these tests. + +pub mod auction; +pub mod solution; + +pub use solution::Solutions; diff --git a/crates/solana-driver/src/infra/solver/dto/solution.rs b/crates/solana-driver/src/infra/solver/dto/solution.rs new file mode 100644 index 0000000000..e3bf64e9a8 --- /dev/null +++ b/crates/solana-driver/src/infra/solver/dto/solution.rs @@ -0,0 +1,279 @@ +//! Inbound `/solve` response: the solutions a solver engine returns. +//! +//! The wire format matches `solana-solvers/src/dto/solution.rs`. + +use { + crate::{domain, domain::order_uid::OrderUid, infra::solver::dto::auction::Auction}, + serde::Deserialize, + serde_with::serde_as, + solana_sdk::{ + instruction::{AccountMeta as SdkAccountMeta, Instruction as SdkInstruction}, + pubkey::Pubkey, + }, + std::collections::HashMap, +}; + +/// The solutions one engine returned for one auction. This wrapper owns the +/// conversion into domain solutions. +#[derive(Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Solutions { + solutions: Vec, +} + +/// A solution in the driver's `/solve` DTO. +#[serde_as] +#[derive(Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Solution { + pub id: u64, + pub trades: Vec, + pub interactions: Vec, + /// Optional solver estimate of total settlement compute units. + #[serde(default)] + pub cu_estimate: Option, + /// The address lookup tables the interactions assume. + #[serde(default)] + #[serde_as(as = "Vec")] + pub address_lookup_tables: Vec, +} + +/// A fulfillment of one auction order. +#[serde_as] +#[derive(Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Trade { + /// The order's 32-byte intent hash. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub order_uid: OrderUid, + /// Sell-token units for sell orders, buy-token units for buy orders. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub executed_amount: u64, +} + +/// A Solana instruction the solver supplies, carried verbatim. +#[serde_as] +#[derive(Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Instruction { + #[serde_as(as = "serde_with::DisplayFromStr")] + pub program_id: Pubkey, + pub accounts: Vec, + #[serde_as(as = "serde_with::base64::Base64")] + pub instruction_data: Vec, +} + +/// Account meta in the driver DTO shape. +#[serde_as] +#[derive(Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountMeta { + #[serde_as(as = "serde_with::DisplayFromStr")] + pub pubkey: Pubkey, + pub is_signer: bool, + pub is_writable: bool, +} + +impl From for SdkAccountMeta { + fn from(value: AccountMeta) -> Self { + Self { + pubkey: value.pubkey, + is_signer: value.is_signer, + is_writable: value.is_writable, + } + } +} + +impl From for SdkInstruction { + fn from(value: Instruction) -> Self { + Self { + program_id: value.program_id, + accounts: value.accounts.into_iter().map(Into::into).collect(), + data: value.instruction_data, + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum Error { + /// A trade references an order that was not in the sent auction. + #[error("trade references unknown order UID {0}")] + UnknownOrderUid(OrderUid), + /// A trade executes more than the order amount. + #[error("trade {0} executes {1} but order amount is {2}")] + ExecutedAmountExceedsOrderAmount(OrderUid, u64, u64), +} + +impl Solutions { + /// Convert the wire solutions into domain solutions. + /// + /// Each trade must reference an order from the auction the driver sent. + /// Any trade referencing an unknown order UID rejects the entire engine + /// response. + pub fn into_domain( + self, + auction: &Auction, + solver: Pubkey, + ) -> Result, Error> { + let allowed_orders: HashMap = + auction.orders.iter().map(|o| (o.uid, o.amount)).collect(); + + self.solutions + .into_iter() + .map(|solution| { + let trades = solution + .trades + .into_iter() + .map(|trade| { + let amount = match allowed_orders.get(&trade.order_uid) { + Some(&amount) => amount, + None => return Err(Error::UnknownOrderUid(trade.order_uid)), + }; + if trade.executed_amount > amount { + return Err(Error::ExecutedAmountExceedsOrderAmount( + trade.order_uid, + trade.executed_amount, + amount, + )); + } + Ok(domain::Trade { + order_uid: trade.order_uid, + executed_amount: trade.executed_amount, + }) + }) + .collect::>()?; + + Ok(domain::Solution { + id: solution.id, + solver, + trades, + interactions: solution.interactions.into_iter().map(Into::into).collect(), + address_lookup_tables: solution.address_lookup_tables, + cu_estimate: solution.cu_estimate, + }) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::{domain::Side, util}, + serde_json::json, + solana_sdk::pubkey::Pubkey, + }; + + fn pubkey(byte: u8) -> Pubkey { + Pubkey::new_from_array([byte; 32]) + } + + fn sample_auction_dto() -> Auction { + Auction { + id: 1, + taker: pubkey(3), + orders: vec![super::super::auction::Order { + uid: OrderUid([8; 32]), + sell_mint: pubkey(1), + buy_mint: pubkey(2), + buy_destination: util::associated_token_address(&pubkey(3), &pubkey(2)), + amount: 1_000, + side: Side::Sell, + }], + deadline: chrono::Utc::now() + chrono::Duration::seconds(60), + } + } + + #[test] + fn rejects_executed_amount_exceeding_order() { + let bad = json!({ + "solutions": [{ + "id": 1, + "trades": [{ + "orderUid": format!("0x{}", "08".repeat(32)), + "executedAmount": "1001", + }], + "interactions": [], + "addressLookupTables": [], + }], + }); + let solutions: Solutions = serde_json::from_value(bad).unwrap(); + let err = solutions + .into_domain(&sample_auction_dto(), pubkey(6)) + .unwrap_err(); + assert_eq!( + err, + Error::ExecutedAmountExceedsOrderAmount(OrderUid([8; 32]), 1001, 1000) + ); + } + + #[test] + fn rejects_unknown_order_uid() { + let bad = json!({ + "solutions": [{ + "id": 1, + "trades": [{ + "orderUid": format!("0x{}", "ff".repeat(32)), + "executedAmount": "1000", + }], + "interactions": [], + "addressLookupTables": [], + }], + }); + let solutions: Solutions = serde_json::from_value(bad).unwrap(); + let err = solutions + .into_domain(&sample_auction_dto(), pubkey(6)) + .unwrap_err(); + assert_eq!(err, Error::UnknownOrderUid(OrderUid([0xff; 32]))); + } + + /// Pins the inbound `/solve` response shape against the literal the + /// `solana-solvers` `Solution` serializes. + #[test] + fn wire_format_is_stable() { + let json = json!({ + "solutions": [{ + "id": 1, + "trades": [{ + "orderUid": format!("0x{}", "08".repeat(32)), + "executedAmount": "1000", + }], + "interactions": [{ + "programId": pubkey(9).to_string(), + "accounts": [{ + "pubkey": pubkey(4).to_string(), + "isSigner": true, + "isWritable": false, + }], + "instructionData": "3q0=", + }], + "addressLookupTables": [pubkey(7).to_string()], + }] + }); + + let expected = Solutions { + solutions: vec![Solution { + id: 1, + trades: vec![Trade { + order_uid: OrderUid([8; 32]), + executed_amount: 1_000, + }], + interactions: vec![Instruction { + program_id: pubkey(9), + accounts: vec![AccountMeta { + pubkey: pubkey(4), + is_signer: true, + is_writable: false, + }], + instruction_data: vec![0xde, 0xad], + }], + cu_estimate: None, + address_lookup_tables: vec![pubkey(7)], + }], + }; + + let actual: Solutions = serde_json::from_value(json).unwrap(); + assert_eq!(actual, expected); + } +} diff --git a/crates/solana-driver/src/infra/solver/mod.rs b/crates/solana-driver/src/infra/solver/mod.rs new file mode 100644 index 0000000000..88ed420db7 --- /dev/null +++ b/crates/solana-driver/src/infra/solver/mod.rs @@ -0,0 +1,151 @@ +//! HTTP client for solver engines. +//! +//! The driver posts each auction to the configured engines on `/solve` and +//! collects their solutions. Engines are opaque HTTP services. All +//! Jupiter-specific behavior lives in the `solana-solvers` crate, not here. + +use { + crate::{ + domain, + infra::{config, solver::dto::auction::Auction}, + }, + solana_sdk::pubkey::Pubkey, + std::sync::Arc, + thiserror::Error, + tokio::sync::Semaphore, +}; + +pub mod dto; + +/// A configured solver engine HTTP client. +#[derive(Debug, Clone)] +pub struct Solver { + name: String, + account: Pubkey, + client: reqwest::Client, + base_url: reqwest::Url, + in_flight: Arc, +} + +impl Solver { + /// Build a solver client from its configuration. + pub fn new(config: &config::Solver) -> Self { + Self { + name: config.name.clone(), + account: config.account, + client: reqwest::Client::new(), + base_url: config.endpoint.clone(), + in_flight: Arc::new(Semaphore::new(config.max_in_flight.get())), + } + } + + /// POST the auction to this engine's `/solve` endpoint and return the + /// domain solutions it produced. + #[tracing::instrument(name = "solver_engine", skip_all, fields(solver = %self.name))] + pub async fn solve(&self, auction: &domain::Auction) -> Result, Error> { + let auction_dto = Auction::new(auction, self.account); + let body = serde_json::to_string(&auction_dto)?; + + let solve_url = self.base_url.join("solve").expect("valid /solve path"); + + let _permit = self + .in_flight + .acquire() + .await + .expect("semaphore is never closed"); + + // Calculate the time remaining until the auction's deadline. This is + // computed *after* acquiring the permit, otherwise the wait could + // silently eat into the budget and let the solve run past the deadline. + // + // TODO: Split the deadline budget between solver time and driver processing + // time. The EVM driver uses `solving_share_of_deadline` to give the solver a + // configurable fraction of the remaining time, leaving the rest for building + let timeout = { + let remaining = auction.deadline.signed_duration_since(chrono::Utc::now()); + if remaining <= chrono::Duration::zero() { + tracing::warn!( + solver = %self.name, + "auction deadline exceeded before sending request to solver" + ); + return Err(Error::DeadlineExceeded); + } + // Safe: we just checked `remaining` is positive. + remaining.to_std().unwrap() + }; + let request = self + .client + .post(solve_url.as_str()) + .header("content-type", "application/json") + .timeout(timeout) + .body(body); + + tracing::debug!(url = %solve_url, "sending solve request"); + + let response = request.send().await?; + let status = response.status(); + if !status.is_success() { + return Err(Error::HttpStatus { + status, + body: response.text().await?, + }); + } + + let solutions: dto::Solutions = response.json().await?; + solutions + .into_domain(&auction_dto, self.account) + .map_err(Error::BadResponse) + } +} + +#[derive(Debug, Error)] +pub enum Error { + /// An HTTP error occurred while talking to the solver. + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + /// The solver returned a non-success HTTP status. + #[error("solver returned HTTP {status}: {body}")] + HttpStatus { + status: reqwest::StatusCode, + body: String, + }, + /// The solver returned a response the driver could not interpret. + #[error("bad solver response: {0}")] + BadResponse(#[from] dto::solution::Error), + /// The request body could not be serialized. + #[error("JSON serialization error: {0}")] + Serialize(#[from] serde_json::Error), + /// The auction deadline passed before a solve request could be sent. + #[error("auction deadline exceeded")] + DeadlineExceeded, +} + +#[cfg(test)] +mod tests { + use {super::*, std::num::NonZero}; + + #[tokio::test] + async fn solve_with_past_deadline_is_rejected() { + // Build a solver pointing at a port that is never listened on. The + // deadline check fires before any HTTP request is sent, so this never + // actually connects to the endpoint. + let solver = Solver::new(&config::Solver { + name: "test".to_owned(), + endpoint: "http://127.0.0.1:1".parse().unwrap(), + account: Pubkey::default(), + max_in_flight: NonZero::new(1).unwrap(), + }); + let auction = domain::Auction { + id: 0, + orders: Vec::new(), + // Well in the past: the request must be skipped entirely. + deadline: chrono::Utc::now() - chrono::Duration::seconds(10), + }; + + let err = solver.solve(&auction).await.expect_err("solve should fail"); + assert!( + matches!(err, Error::DeadlineExceeded), + "expected DeadlineExceeded, got {err:?}" + ); + } +} diff --git a/crates/solana-driver/src/lib.rs b/crates/solana-driver/src/lib.rs index 8feb7218bf..f08eb0b05e 100644 --- a/crates/solana-driver/src/lib.rs +++ b/crates/solana-driver/src/lib.rs @@ -2,7 +2,9 @@ #![forbid(unsafe_code)] +pub mod domain; pub mod infra; mod run; +pub mod util; pub use self::run::{run, start}; diff --git a/crates/solana-driver/src/run.rs b/crates/solana-driver/src/run.rs index 48bc4fe5fa..eba473bf5e 100644 --- a/crates/solana-driver/src/run.rs +++ b/crates/solana-driver/src/run.rs @@ -1,7 +1,7 @@ //! Driver entry-point logic. use { - crate::infra::{Api, config, observe as infra_observe}, + crate::infra::{Api, config, observe as infra_observe, solver}, clap::Parser, cow_solana_rpc::{CommitmentConfig, SolanaRPC}, std::{path::PathBuf, time::Duration}, @@ -40,9 +40,11 @@ pub async fn run(args: Args) { config.rpc.request_timeout, CommitmentConfig::confirmed(), ); + let solvers: Vec = config.solvers.iter().map(solver::Solver::new).collect(); let api = Api { addr: config.http.bind_address, rpc, + solvers, }; let (listener, _addr) = api.bind().await.expect("failed to bind HTTP server"); let serve = api.serve(listener, shutdown_token.clone()); diff --git a/crates/solana-driver/src/util/mod.rs b/crates/solana-driver/src/util/mod.rs new file mode 100644 index 0000000000..96b8131a80 --- /dev/null +++ b/crates/solana-driver/src/util/mod.rs @@ -0,0 +1,47 @@ +//! Small shared helpers internal to the driver crate. + +use solana_sdk::pubkey::Pubkey; + +/// SPL Associated Token Account program ID +/// (`ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL`). +const SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); + +/// SPL Token program ID (`TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`). +const SPL_TOKEN_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + +/// Derive the associated token account (ATA) address for `owner` and `mint` +/// under the SPL Token program. +/// +/// TODO(token-2022): this derivation hard-codes the SPL Token program ID as the +/// mint's token program in the PDA seed. Token-2022 mints live under a +/// different program, whose ATA addresses derive with a different seed set, so +/// this returns the wrong address for them. +/// +/// To support token-2022 we'd need to look up the mint's token program (e.g. +/// via `get_account_info` on the mint) and use that program ID in the seeds. +pub fn associated_token_address(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[owner.as_ref(), SPL_TOKEN_PROGRAM_ID.as_ref(), mint.as_ref()], + &SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, + ) + .0 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Cross-checked against the known WSOL ATA for the system program owner + /// on Solana mainnet. + #[test] + fn derives_associated_token_address() { + let owner = Pubkey::from_str_const("11111111111111111111111111111111"); + let mint = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + assert_eq!( + associated_token_address(&owner, &mint), + Pubkey::from_str_const("aqxoAhCwpy3oB1BpNw9hL1HdLYLgPpbPjzxDrrQj3Fs"), + ); + } +} diff --git a/crates/solana-driver/tests/api.rs b/crates/solana-driver/tests/api.rs index 4f4770a264..16b89aa3a2 100644 --- a/crates/solana-driver/tests/api.rs +++ b/crates/solana-driver/tests/api.rs @@ -11,6 +11,7 @@ fn mock_api() -> Api { Api { addr: "0.0.0.0:0".parse().unwrap(), rpc: SolanaRPC::new_mock("succeeds".to_string()), + solvers: Vec::new(), } } diff --git a/crates/solana-driver/tests/jupiter_live.rs b/crates/solana-driver/tests/jupiter_live.rs new file mode 100644 index 0000000000..8861d9e8ba --- /dev/null +++ b/crates/solana-driver/tests/jupiter_live.rs @@ -0,0 +1,192 @@ +//! Live integration test against the in-repo Jupiter solver engine. +//! +//! Spins up the real `solana-solvers` HTTP API in-process with Jupiter pointed +//! at the live swap API, then exercises the driver's `Solver` client against +//! it. This crosses the real driver <-> solver wire boundary (serialization on +//! both sides, the solve loop, and Jupiter quote/swap-instruction parsing). +//! +//! Network-dependent and non-deterministic (Jupiter routes/amounts vary), so +//! it is `#[ignore]` by default. Run on demand: +//! +//! ```text +//! cargo nextest run -p solana-driver --run-ignored ignored-only --test jupiter_live +//! # set JUPITER_API_KEY for rate-limit headroom: +//! JUPITER_API_KEY=... cargo nextest run -p solana-driver --run-ignored ignored-only --test jupiter_live +//! # override the auction deadline (default 15s): +//! SOLANA_DRIVER_TEST_DEADLINE=30 cargo nextest run -p solana-driver --run-ignored ignored-only --test jupiter_live +//! # show the full deserialized solution (printed by the test): +//! cargo nextest run -p solana-driver --run-ignored ignored-only --test jupiter_live --nocapture +//! ``` + +use { + solana_driver::{ + domain::{Auction, Order, Side, order_uid::OrderUid}, + infra::{config, solver::Solver}, + util::associated_token_address, + }, + solana_sdk::pubkey::Pubkey, + solana_solvers::{ + api::Api, + config::JupiterConfig, + dex::{Dex, jupiter::Jupiter}, + }, + std::{str::FromStr, sync::Arc}, + tokio_util::sync::CancellationToken, +}; + +// USDC and USDT, both 6-decimal stablecoins on Solana mainnet. +const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +const USDT: &str = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"; + +/// Auction deadline for the live test (seconds). +fn deadline() -> chrono::DateTime { + let secs = std::env::var("SOLANA_DRIVER_TEST_DEADLINE") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|s: &i64| *s > 0) + .unwrap_or(15); + chrono::Utc::now() + chrono::Duration::seconds(secs) +} + +/// A sell of 10 USDC for USDT. The driver derives the buy-side ATA from the +/// solver's account, so the domain order carries no destination. +fn sell_auction() -> Auction { + Auction { + id: 1, + orders: vec![Order { + uid: OrderUid([8; 32]), + sell_mint: Pubkey::from_str(USDC).unwrap(), + buy_mint: Pubkey::from_str(USDT).unwrap(), + amount: 10_000_000, + side: Side::Sell, + }], + deadline: deadline(), + } +} + +/// Serve the real solver engine on an ephemeral port, returning the address +/// the driver should target and a token that stops the server when cancelled. +async fn spawn_solver() -> (std::net::SocketAddr, CancellationToken) { + let jupiter = Jupiter::new(&JupiterConfig { + endpoint: "https://api.jup.ag".parse().unwrap(), + api_key: std::env::var("JUPITER_API_KEY").ok(), + slippage_bps: 50, + enable_buy_orders: false, + }) + .expect("build jupiter dex"); + + let api = Api { + addr: "127.0.0.1:0".parse().unwrap(), + dex: Arc::new(Dex::Jupiter(jupiter)), + }; + let (listener, addr) = api.bind().await.expect("bind solver engine"); + let shutdown = CancellationToken::new(); + let token = shutdown.clone(); + tokio::spawn(async move { + // Any shutdown future works; the token is never cancelled in the happy + // path, the task is dropped with the test. + let _ = api + .serve(listener, async move { + token.cancelled().await; + }) + .await; + }); + (addr, shutdown) +} + +/// The driver's solver client posts the auction to a live Jupiter-backed +/// engine and maps the response back into domain solutions. +#[tokio::test] +#[ignore = "hits the live Jupiter swap API; needs network"] +async fn driver_solves_against_live_jupiter_engine() { + let (addr, _shutdown) = spawn_solver().await; + + // Any valid pubkey works: Jupiter builds instructions for this account, + // the swap only runs for real once the driver submits the settlement. + let solver_account = Pubkey::new_unique(); + let solver = Solver::new(&config::Solver { + name: "jupiter-live".to_string(), + endpoint: format!("http://{addr}").parse().unwrap(), + account: solver_account, + max_in_flight: std::num::NonZero::new(1).unwrap(), + }); + + // `Solver::solve` posts the auction and deserializes the JSON response + // into `domain::Solution`s; an `Ok` result proves the wire deserialization + // succeeded. + let solutions = solver + .solve(&sell_auction()) + .await + .expect("solve should succeed against live Jupiter"); + + assert_eq!(solutions.len(), 1, "one solution for the single order"); + let solution = &solutions[0]; + + // --- solution identity --- + // Index 0 for the single order; the driver stamps `solver` with the + // configured account. + assert_eq!(solution.id, 0, "solution id is the order index"); + assert_eq!(solution.solver, solver_account); + + // --- trade --- + // One trade fulfilling our order. The wire format carries no fee. + assert_eq!(solution.trades.len(), 1); + let trade = &solution.trades[0]; + assert_eq!(trade.order_uid, OrderUid([8; 32])); + assert_eq!(trade.executed_amount, 10_000_000, "full sell amount filled"); + + // --- interactions --- + // The swap must arrive as real Solana instructions: every interaction + // targets a non-default program, and at least one carries instruction data. + assert!( + !solution.interactions.is_empty(), + "Jupiter must return at least the swap instruction" + ); + for ix in &solution.interactions { + assert!( + ix.program_id != Pubkey::default(), + "interaction targets a real program, not the zero address" + ); + } + assert!( + solution.interactions.iter().any(|ix| !ix.data.is_empty()), + "at least one interaction carries instruction data" + ); + + // The swap instructions must be built for our settlement signer and land + // the buy output in the ATA the driver derived from it. This is the + // end-to-end check that the `buy_destination` derivation flows through the + // whole driver <-> solver <-> Jupiter path. + let buy_destination = + associated_token_address(&solver_account, &Pubkey::from_str(USDT).unwrap()); + let touched: Vec = solution + .interactions + .iter() + .flat_map(|ix| ix.accounts.iter().map(|a| a.pubkey)) + .collect(); + assert!( + touched.contains(&solver_account), + "swap instructions must reference the settlement signer {solver_account}; touched \ + accounts: {touched:?}" + ); + assert!( + touched.contains(&buy_destination), + "swap instructions must send output to the derived buy ATA {buy_destination}; touched \ + accounts: {touched:?}" + ); + + // --- address lookup tables --- + // A v0 transaction can carry zero address lookup tables, but a Jupiter + // swap route touches enough accounts that it returns at least one. + assert!( + !solution.address_lookup_tables.is_empty(), + "Jupiter swap route should return address lookup tables" + ); + + // --- compute estimate --- + // The solver does not estimate compute units. + assert_eq!(solution.cu_estimate, None); + + // Surface the full deserialized solution for `--nocapture` inspection. + println!("deserialized solution: {solution:#?}"); +} diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index 0eacadea52..03713c7ab7 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" [dependencies] axum = { workspace = true } base64 = { workspace = true } +chrono = { workspace = true, default-features = false, features = ["clock"] } clap = { workspace = true, features = ["derive", "env"] } const-hex = { workspace = true } futures = { workspace = true } diff --git a/crates/solana-solvers/src/api.rs b/crates/solana-solvers/src/api.rs index 995e9064e7..f39cb04e5e 100644 --- a/crates/solana-solvers/src/api.rs +++ b/crates/solana-solvers/src/api.rs @@ -23,9 +23,19 @@ pub struct Api { } impl Api { - /// Bind and serve until `shutdown` resolves. + /// Bind to the configured address, returning the listener and the actual + /// bound address (which differs from `addr` when binding to port 0). + pub async fn bind(&self) -> std::io::Result<(tokio::net::TcpListener, SocketAddr)> { + let listener = tokio::net::TcpListener::bind(self.addr).await?; + let local_addr = listener.local_addr()?; + tracing::info!(addr = %local_addr, "solana-solvers listening"); + Ok((listener, local_addr)) + } + + /// Serve the API on the given listener until `shutdown` resolves. pub async fn serve( self, + listener: tokio::net::TcpListener, shutdown: impl Future + Send + 'static, ) -> std::io::Result<()> { let app = Router::new() @@ -35,8 +45,6 @@ impl Api { .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 diff --git a/crates/solana-solvers/src/domain/solver/mod.rs b/crates/solana-solvers/src/domain/solver/mod.rs index 0c908fb44f..41e8ac1c50 100644 --- a/crates/solana-solvers/src/domain/solver/mod.rs +++ b/crates/solana-solvers/src/domain/solver/mod.rs @@ -36,6 +36,9 @@ impl Quote for Dex { /// /// Order counts are small (bounded by the settlement account budget), so every /// order is quoted at once. +/// +/// TODO: Enforce `auction.deadline` with a timeout, like the EVM dex solver +/// does (`crates/solvers/src/domain/solver/dex/mod.rs`). 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(); @@ -58,6 +61,10 @@ mod tests { Pubkey::new_from_array([byte; 32]) } + fn deadline() -> chrono::DateTime { + chrono::Utc::now() + chrono::Duration::seconds(60) + } + fn order(uid: u8, side: dex::Side, sell_mint: Pubkey) -> auction::Order { auction::Order { uid: OrderUid([uid; 32]), @@ -104,6 +111,7 @@ mod tests { order(0x02, dex::Side::Sell, pubkey(0xff)), // no route order(0x03, dex::Side::Buy, pubkey(0x11)), // buys disabled ], + deadline: deadline(), }; let solutions = solve(&MockQuote, &auction).await; @@ -118,6 +126,7 @@ mod tests { id: 1, taker: pubkey(1), orders: vec![], + deadline: deadline(), }; 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 index f850a55439..3ec9c98eb0 100644 --- a/crates/solana-solvers/src/dto/auction.rs +++ b/crates/solana-solvers/src/dto/auction.rs @@ -17,11 +17,13 @@ use { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Auction { - pub id: u64, + pub id: i64, /// Settlement signer the swap instructions are built for. #[serde_as(as = "serde_with::DisplayFromStr")] pub taker: Pubkey, pub orders: Vec, + /// Absolute deadline by which solutions must be returned. + pub deadline: chrono::DateTime, } /// One order to quote. diff --git a/crates/solana-solvers/src/run.rs b/crates/solana-solvers/src/run.rs index 0f816edff7..aaece3250b 100644 --- a/crates/solana-solvers/src/run.rs +++ b/crates/solana-solvers/src/run.rs @@ -34,7 +34,14 @@ pub async fn start(args: impl IntoIterator) { addr: args.addr, dex: Arc::new(dex::Dex::Jupiter(jupiter)), }; - if let Err(err) = api.serve(observe::shutdown::shutdown_signal()).await { + let (listener, _addr) = api + .bind() + .await + .unwrap_or_else(|err| panic!("bind solana-solvers: {err}")); + if let Err(err) = api + .serve(listener, observe::shutdown::shutdown_signal()) + .await + { tracing::error!(?err, "server error"); } }