diff --git a/Cargo.lock b/Cargo.lock index 1ac4105e4a..1a16335142 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10835,6 +10835,7 @@ dependencies = [ "axum 0.8.8", "base64 0.22.1", "clap", + "const-hex", "observe", "reqwest 0.13.4", "serde", diff --git a/crates/solana-solvers/Cargo.toml b/crates/solana-solvers/Cargo.toml index fbf4331ece..d05629b790 100644 --- a/crates/solana-solvers/Cargo.toml +++ b/crates/solana-solvers/Cargo.toml @@ -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"] } diff --git a/crates/solana-solvers/src/dex/jupiter/dto.rs b/crates/solana-solvers/src/dex/jupiter/dto.rs index db9b971b02..56244fea20 100644 --- a/crates/solana-solvers/src/dex/jupiter/dto.rs +++ b/crates/solana-solvers/src/dex/jupiter/dto.rs @@ -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 { diff --git a/crates/solana-solvers/src/dex/jupiter/mod.rs b/crates/solana-solvers/src/dex/jupiter/mod.rs index 1c2ba9aab8..7f019e9dc6 100644 --- a/crates/solana-solvers/src/dex/jupiter/mod.rs +++ b/crates/solana-solvers/src/dex/jupiter/mod.rs @@ -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 } @@ -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` diff --git a/crates/solana-solvers/src/dto/mod.rs b/crates/solana-solvers/src/dto/mod.rs new file mode 100644 index 0000000000..1c2cdc1648 --- /dev/null +++ b/crates/solana-solvers/src/dto/mod.rs @@ -0,0 +1,4 @@ +//! Wire DTOs the solver engine emits: the solution the driver consumes. + +pub mod order; +pub mod solution; diff --git a/crates/solana-solvers/src/dto/order.rs b/crates/solana-solvers/src/dto/order.rs new file mode 100644 index 0000000000..487faeb762 --- /dev/null +++ b/crates/solana-solvers/src/dto/order.rs @@ -0,0 +1,27 @@ +//! 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(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} diff --git a/crates/solana-solvers/src/dto/solution.rs b/crates/solana-solvers/src/dto/solution.rs new file mode 100644 index 0000000000..77f6cd51f9 --- /dev/null +++ b/crates/solana-solvers/src/dto/solution.rs @@ -0,0 +1,254 @@ +//! 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 as SolInstruction, pubkey::Pubkey}, +}; + +/// A solution in the driver's `/solve` DTO. Trades fulfill auction orders, with +/// no JIT. +#[serde_as] +#[derive(Debug, Serialize)] +#[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(skip_serializing_if = "Option::is_none")] + pub cu_estimate: Option, + /// The address lookup tables the interactions assume, carried through so + /// the driver can build the v0 transaction around them. + #[serde_as(as = "Vec")] + pub address_lookup_tables: Vec, +} + +/// A fulfillment of one auction order. +#[serde_as] +#[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. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub executed_amount: u64, +} + +/// A Solana instruction the solver supplies, carried verbatim. +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Instruction { + #[serde_as(as = "serde_with::DisplayFromStr")] + pub program_id: Pubkey, + pub accounts: Vec, + /// Base64-encoded instruction data. + #[serde(serialize_with = "serialize_base64")] + pub instruction_data: Vec, +} + +/// 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, which is not a real trade. + #[error("sell and buy mint are the same")] + SameMint, + /// A zero quoted amount means the swap fills nothing. + #[error("quoted amount is zero")] + ZeroAmount, +} + +impl Solution { + /// Wraps one quoted swap into a single-order solution. + /// + /// The swap's instructions are carried verbatim as interactions, and + /// its address lookup tables travel along so the driver can build the + /// v0 transaction the instructions assume. + pub fn new( + id: u64, + order_uid: OrderUid, + order: &dex::Order, + swap: dex::Swap, + ) -> Result { + 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, + trades: vec![Trade { + order_uid, + executed_amount, + }], + interactions: swap + .instructions + .into_iter() + .map(Instruction::from_sdk) + .collect(), + cu_estimate: None, + address_lookup_tables: swap.address_lookup_tables, + }) + } +} + +impl Instruction { + fn from_sdk(instruction: SolInstruction) -> Self { + Self { + program_id: instruction.program_id, + accounts: instruction + .accounts + .into_iter() + .map(|meta| AccountMeta { + pubkey: meta.pubkey, + is_signer: meta.is_signer, + is_writable: meta.is_writable, + }) + .collect(), + instruction_data: instruction.data, + } + } +} + +fn serialize_base64(data: &[u8], serializer: S) -> Result { + serializer.serialize_str(&BASE64_STANDARD.encode(data)) +} + +#[cfg(test)] +mod tests { + use {super::*, solana_sdk::instruction::AccountMeta as SdkAccountMeta, std::str::FromStr}; + + const ORDER_UID: OrderUid = OrderUid([8; 32]); + + 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![SolInstruction { + 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::new(42, ORDER_UID, &order, swap()).unwrap(); + + assert_eq!(solution.id, 42); + assert_eq!(solution.trades.len(), 1); + assert_eq!(solution.trades[0].order_uid, ORDER_UID); + assert_eq!(solution.trades[0].executed_amount, 1_000); + assert_eq!(solution.address_lookup_tables, vec![pubkey(7)]); + + // The instruction is carried verbatim, flags included. + let interaction = &solution.interactions[0]; + assert_eq!(interaction.program_id, pubkey(9)); + assert_eq!(interaction.accounts[0].pubkey, pubkey(4)); + assert!(interaction.accounts[0].is_signer); + assert!(!interaction.accounts[0].is_writable); + assert_eq!(interaction.instruction_data, vec![0xde, 0xad]); + } + + #[test] + fn buy_swap_executes_in_buy_token_units() { + let solution = Solution::new(0, ORDER_UID, &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::new(0, ORDER_UID, &order, swap()).unwrap_err(), + Error::SameMint + ); + } + + #[test] + fn zero_quoted_amount_is_rejected() { + let mut swap = swap(); + swap.out_amount = 0; + assert_eq!( + Solution::new(0, ORDER_UID, &order(dex::Side::Sell), swap).unwrap_err(), + Error::ZeroAmount + ); + } + + #[test] + fn wire_format_is_stable() { + let solution = Solution::new(1, ORDER_UID, &order(dex::Side::Sell), swap()).unwrap(); + let json = serde_json::to_value(&solution).unwrap(); + + assert_eq!( + json, + serde_json::json!({ + "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": BASE64_STANDARD.encode([0xde, 0xad]), + }], + "addressLookupTables": [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); + } +} diff --git a/crates/solana-solvers/src/lib.rs b/crates/solana-solvers/src/lib.rs index 98f1a02c77..3e7996bf05 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 dto; mod run; pub use run::start;