diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f32e70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +.env \ No newline at end of file diff --git a/README.md b/README.md index f0111ed..bfea8df 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,17 @@ Options: Our CLI offers the following pathways: -- 🌞 `solana`: For Solana -> Solana single domain intents -- 🌙 `ethereum`: For Ethereum -> Ethereum single domain intents +- 🌞 `new-intent`: Submit a new intent into the network +- 🌞 `query-quote`: Submit a new intent into the network + +Beneath each of these commands there are 4 sub-commands: + +- 🌞 `solana`: For Solana -> Solana single domain interactions +- 🌙 `ethereum`: For Ethereum -> Ethereum single domain interactions - 🌠 `solana-ethereum`: For the daring Solana -> Ethereum cross-domain - 🌌 `ethereum-solana`: For the brave Ethereum -> Solana cross-domain -## 🧭 Command Details +## 🧭 new-intent Command Details ### 🌞 Solana Single Domain @@ -70,6 +75,26 @@ cargo run -- ethereum-solana ArgMatches { + let quote_query_args = quote_query_args(); Command::new("Mantis SDK Intent CLI") .version("1.0") .about("Handles Solana and Ethereum escrow intents. Single Domain & Cross Domain") .subcommand( - Command::new("solana") - .about("Solana -> Solana single domain intent") - .args(common_args()), // Use common_args for Solana + Command::new("submit-intent") + .about("Submit a new intent to the network") + .subcommand( + Command::new("solana") + .about("Solana -> Solana single domain intent") + .args(common_args()), // Use common_args for Solana + ) + .subcommand( + Command::new("solana-ethereum") + .about("Solana -> Ethereum cross-domain intent") + .args(cross_domain_args()), // Use cross_domain_args for cross domain + ) + .subcommand( + Command::new("ethereum") + .about("Ethereum -> Ethereum single domain intent") + .args(common_args_ethereum()), // Use common_args_ethereum for Ethereum + ) + .subcommand( + Command::new("ethereum-solana") + .about("Ethereum -> Solana cross-domain intent") + .args(cross_domain_args_ethereum()), // Use Ethereum cross domain args + ) ) .subcommand( - Command::new("solana-ethereum") - .about("Solana -> Ethereum cross-domain intent") - .args(cross_domain_args()), // Use cross_domain_args for cross domain - ) - .subcommand( - Command::new("ethereum") - .about("Ethereum -> Ethereum single domain intent") - .args(common_args_ethereum()), // Use common_args_ethereum for Ethereum - ) - .subcommand( - Command::new("ethereum-solana") - .about("Ethereum -> Solana cross-domain intent") - .args(cross_domain_args_ethereum()), // Use Ethereum cross domain args + Command::new("query-quote") + .about("Query an amount of output tokens offered for a given amount of input tokens") + .subcommand( + Command::new("solana") + .about("Solana -> Solana single domain swap") + .args(quote_query_args.clone()), + ) + .subcommand( + Command::new("solana-ethereum") + .about("Solana -> Ethereum cross-domain swap") + .args(quote_query_args.clone()), + ) + .subcommand( + Command::new("ethereum") + .about("Ethereum -> Ethereum single domain swap") + .args(quote_query_args.clone()), + ) + .subcommand( + Command::new("ethereum-solana") + .about("Ethereum -> Solana cross-domain swap") + .args(quote_query_args), + ) ) .get_matches() } @@ -115,4 +144,25 @@ fn cross_domain_args_ethereum() -> Vec { .help("Destination user address"), ); args -} \ No newline at end of file +} + +fn quote_query_args() -> Vec { + vec![ + Arg::new("token_in") + .required(true) + .help("Input token address"), + Arg::new("src_address") + .required(true) + .help("The address where input tokens are coming from"), + Arg::new("amount") + .required(true) + .value_parser(clap::value_parser!(u64)) + .help("Amount of input tokens to be swapped"), + Arg::new("dst_address") + .required(true) + .help("The address where output tokens are going to"), + Arg::new("token_out") + .required(true) + .help("Output token address"), + ] +} diff --git a/user/src/main.rs b/user/src/main.rs index 9eb3eb3..51741e9 100644 --- a/user/src/main.rs +++ b/user/src/main.rs @@ -1,6 +1,7 @@ +mod cli; mod ethereum; +mod quotes; mod solana; -mod cli; use std::env; use std::rc::Rc; @@ -11,47 +12,56 @@ use anchor_client::solana_sdk::signature::Keypair; use anchor_client::{Client, Cluster}; use anyhow::Result; use clap::ArgMatches; +use ethers::types::Address; +use ethers::types::U256; use rand::{distributions::Alphanumeric, Rng}; use solana_sdk::bs58; use solana_sdk::signature::Signer; -use ethers::types::U256; use std::str::FromStr; -use ethers::types::Address; -use crate::cli::parse_common_args; use crate::cli::parse_cli; -use crate::solana::{escrow_and_store_intent_cross_chain_solana, escrow_and_store_intent_solana}; +use crate::cli::parse_common_args; use crate::ethereum::escrow_and_store_intent_ethereum; +use crate::solana::{escrow_and_store_intent_cross_chain_solana, escrow_and_store_intent_solana}; #[tokio::main] async fn main() -> Result<(), Box> { dotenv::dotenv().ok(); let matches = parse_cli(); - // Execute the appropriate function based on the subcommand used - if let Some(solana_matches) = matches.subcommand_matches("solana") { - let solana_matches_cloned = solana_matches.clone(); - tokio::task::spawn_blocking(move || { - handle_solana_single_domain_intent(&solana_matches_cloned) - .unwrap(); - }) - .await - .expect("Failed to execute blocking code on solana"); - } else if let Some(solana_ethereum_matches) = matches.subcommand_matches("solana-ethereum") { - let solana_ethereum_matches_cloned = solana_ethereum_matches.clone(); - tokio::task::spawn_blocking(move || { - handle_solana_ethereum_cross_domain_intent(&solana_ethereum_matches_cloned) - .unwrap(); - }) - .await - .expect("Failed to execute blocking code on solana-ethereum"); - } else if let Some(ethereum_matches) = matches.subcommand_matches("ethereum") { - let ethereum_matches_cloned = ethereum_matches.clone(); + if let Some(new_intent_matches) = matches.subcommand_matches("new-intent") { + // Execute the appropriate function based on the subcommand used + if let Some(solana_matches) = new_intent_matches.subcommand_matches("solana") { + let solana_matches_cloned = solana_matches.clone(); + tokio::task::spawn_blocking(move || { + handle_solana_single_domain_intent(&solana_matches_cloned).unwrap(); + }) + .await + .expect("Failed to execute blocking code on solana"); + } else if let Some(solana_ethereum_matches) = + new_intent_matches.subcommand_matches("solana-ethereum") + { + let solana_ethereum_matches_cloned = solana_ethereum_matches.clone(); + tokio::task::spawn_blocking(move || { + handle_solana_ethereum_cross_domain_intent(&solana_ethereum_matches_cloned) + .unwrap(); + }) + .await + .expect("Failed to execute blocking code on solana-ethereum"); + } else if let Some(ethereum_matches) = new_intent_matches.subcommand_matches("ethereum") { + let ethereum_matches_cloned = ethereum_matches.clone(); handle_ethereum_single_domain_intent(ðereum_matches_cloned) - .await.unwrap(); - } else if let Some(ethereum_solana_matches) = matches.subcommand_matches("ethereum-solana") { + .await + .unwrap(); + } else if let Some(ethereum_solana_matches) = + new_intent_matches.subcommand_matches("ethereum-solana") + { handle_ethereum_solana_cross_domain_intent(ðereum_solana_matches) - .await.unwrap(); + .await + .unwrap(); + } + } else if let Some(query_quote_matches) = matches.subcommand_matches("query-quote") { + handle_quote_query(&query_quote_matches).await.unwrap(); } Ok(()) @@ -71,10 +81,12 @@ async fn handle_ethereum_single_domain_intent(matches: &ArgMatches) -> Result<() amount_in, token_out, amount_out, - String::default(), + String::default(), true, timeout, - ).await { + ) + .await + { Ok(receipt) => { println!("Transaction successful, receipt: {:?}", receipt); } @@ -97,14 +109,10 @@ async fn handle_ethereum_solana_cross_domain_intent(matches: &ArgMatches) -> Res // Call the escrow function for cross domain match escrow_and_store_intent_ethereum( - token_in, - amount_in, - token_out, - amount_out, - dst_user, - false, - timeout, - ).await { + token_in, amount_in, token_out, amount_out, dst_user, false, timeout, + ) + .await + { Ok(receipt) => { println!("Transaction successful, receipt: {:?}", receipt); } @@ -117,16 +125,14 @@ async fn handle_ethereum_solana_cross_domain_intent(matches: &ArgMatches) -> Res } /// Handle the Solana -> Solana intent. -fn handle_solana_single_domain_intent( - matches: &ArgMatches, -) -> Result<()> { +fn handle_solana_single_domain_intent(matches: &ArgMatches) -> Result<()> { let private_key_bytes = - bs58::decode(env::var("SOLANA_KEYPAIR").expect("SOLANA_KEYPAIR must be set")) - .into_vec() - .expect("Failed to decode Base58 private key"); + bs58::decode(env::var("SOLANA_KEYPAIR").expect("SOLANA_KEYPAIR must be set")) + .into_vec() + .expect("Failed to decode Base58 private key"); -let wallet = - Rc::new(Keypair::from_bytes(&private_key_bytes).expect("Failed to create keypair")); + let wallet = + Rc::new(Keypair::from_bytes(&private_key_bytes).expect("Failed to create keypair")); let auctioneer_state = Pubkey::find_program_address(&[b"auctioneer"], &bridge_escrow::ID).0; @@ -169,16 +175,14 @@ let wallet = } /// Handle the Solana -> Ethereum cross-domain intent. -fn handle_solana_ethereum_cross_domain_intent( - matches: &ArgMatches, -) -> Result<()> { +fn handle_solana_ethereum_cross_domain_intent(matches: &ArgMatches) -> Result<()> { let private_key_bytes = - bs58::decode(env::var("SOLANA_KEYPAIR").expect("SOLANA_KEYPAIR must be set")) - .into_vec() - .expect("Failed to decode Base58 private key"); + bs58::decode(env::var("SOLANA_KEYPAIR").expect("SOLANA_KEYPAIR must be set")) + .into_vec() + .expect("Failed to decode Base58 private key"); -let wallet = - Rc::new(Keypair::from_bytes(&private_key_bytes).expect("Failed to create keypair")); + let wallet = + Rc::new(Keypair::from_bytes(&private_key_bytes).expect("Failed to create keypair")); let auctioneer_state = Pubkey::find_program_address(&[b"auctioneer"], &bridge_escrow::ID).0; @@ -229,3 +233,99 @@ fn generate_random_intent_id() -> String { .collect() } +async fn handle_quote_query(matches: &ArgMatches) -> Result<()> { + let auctioneer_url = env::var("AUCTIONEER_URL").expect("AUCTIONEER_URL must be set"); + let (subcmd, cmd_matches) = matches.subcommand().expect("No subcommand given"); + let networks: Vec<&str> = subcmd.split("-").collect(); + let (src_chain, dst_chain) = if networks.len() > 1 { + (networks[0].to_string(), networks[1].to_string()) + } else { + (networks[0].to_string(), networks[0].to_string()) + }; + let query = quotes::Query { + src_chain, + dst_chain, + token_in: cmd_matches + .get_one::("token_in") + .unwrap() + .to_string(), + token_out: cmd_matches + .get_one::("token_out") + .unwrap() + .to_string(), + amount: cmd_matches.get_one::("amount").unwrap().to_string(), + src_address: cmd_matches + .get_one::("src_address") + .unwrap() + .to_string(), + dst_address: cmd_matches + .get_one::("dst_address") + .unwrap() + .to_string(), + }; + match query.exec(&format!("{}/query_quote", auctioneer_url)).await { + Ok(output) => { + let mut solver_width = 0; + let mut token_width = 0; + let mut amount_width = 0; + for quote in output.outputs.iter() { + solver_width = std::cmp::max(solver_width, quote.solver_id.len()); + token_width = std::cmp::max(token_width, quote.quote.token.len()); + amount_width = std::cmp::max(amount_width, quote.quote.amount.len()); + } + let hline = format!( + "+-{}-+-{}-+-{}-+", + "-".repeat(solver_width), + "-".repeat(token_width), + "-".repeat(amount_width) + ); + + println!("Quotes:"); + println!("{}", hline); + println!( + "| {: { + match e { + quotes::QueryError::AuctioneerError(msg) => { + println!("Auctioneer error: {}", msg); + } + quotes::QueryError::HttpError(err) => { + println!("HTTP error: {}", err); + } + } + Err(anyhow::Error::msg("Querying quotes failed")) + } + } +} diff --git a/user/src/quotes.rs b/user/src/quotes.rs new file mode 100644 index 0000000..d8e699a --- /dev/null +++ b/user/src/quotes.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Query { + pub src_chain: String, + pub dst_chain: String, + pub token_in: String, + pub token_out: String, + #[serde(rename = "amount_in")] + pub amount: String, + pub src_address: String, + pub dst_address: String, +} + +pub enum QueryError { + HttpError(reqwest::Error), + AuctioneerError(String), +} + +impl Query { + pub async fn exec_with( + &self, + client: &reqwest::Client, + url: &str, + ) -> Result { + let request = client + .get(url) + .header("Content-Type", "application/json") + .json(self); + let response = request.send().await.map_err(|e| QueryError::HttpError(e))?; + if response.status().is_success() { + response.json().await.map_err(|e| QueryError::HttpError(e)) + } else { + Err(QueryError::AuctioneerError(response.text().await.map_err(|e| QueryError::HttpError(e))?)) + } + } + + pub async fn exec(&self, url: &str) -> Result { + let client = reqwest::Client::new(); + self.exec_with(&client, url).await + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MeanQuote { + #[serde(rename = "token_out")] + pub token: String, + #[serde(rename = "amount_out")] + pub amount: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Quote { + pub solver_id: String, + #[serde(flatten)] + pub quote: MeanQuote, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QuoteResponse { + pub mean_output: MeanQuote, + pub outputs: Vec, +}