diff --git a/skills/0x-trade/references/cross-chain.md b/skills/0x-trade/references/cross-chain.md index ba24852..a45ca0a 100644 --- a/skills/0x-trade/references/cross-chain.md +++ b/skills/0x-trade/references/cross-chain.md @@ -19,6 +19,24 @@ Swap a token on one chain for a token on another in a single command. Supports E - `--sort price|speed` (default `price`) orders the fetched quotes; `--max-quotes` (default 3) caps how many are fetched. - `--sell` is validated against the origin chain's address format, `--buy` against the destination's. +## Route filtering + +Steer which bridges and DEX sources the router may use. All three are comma-separated (or repeat the flag) and optional: + +- `--included-bridges ` — only route through these bridge providers. +- `--excluded-bridges ` — never route through these bridge providers. +- `--excluded-swap-sources ` — exclude these DEX sources on **both** legs. + +`--included-bridges` and `--excluded-bridges` are mutually exclusive — passing both is an input error (exit **2**). Bridge/source names aren't validated client-side; unknown names pass through and the API decides (a filter that removes every route surfaces as `NO_LIQUIDITY`, exit 6). + +```bash +0x cross-chain --from base --to arbitrum \ + --sell 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 \ + --buy 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 \ + --amount 1000000 --excluded-bridges across,stargate \ + --select-quote best-price --yes -o json-envelope +``` + ## The two-step agent pattern Without `--yes`, the CLI emits the selected quote as a preview envelope and exits **25** — nothing is signed. Use this to show the user the bridge, rate, and ETA before committing: diff --git a/src/api/cross_chain.rs b/src/api/cross_chain.rs index 130bdd0..adadc38 100644 --- a/src/api/cross_chain.rs +++ b/src/api/cross_chain.rs @@ -148,51 +148,82 @@ impl CrossChainQuote { } } -impl ApiClient { - /// Get cross-chain quotes. `destination_address` is required by the API - /// for any Solana-side leg (the protocol can't derive a Solana pubkey - /// from an EVM signer), and the API also requires it for EVM-to-EVM in - /// some versions — so we always send it. Callers default it to the - /// signer's address on the destination chain. - #[allow(clippy::too_many_arguments)] - pub async fn get_cross_chain_quotes( - &self, - origin_chain: &str, - destination_chain: &str, - sell_token: &str, - buy_token: &str, - sell_amount: &str, - origin_address: &str, - destination_address: &str, - slippage_bps: Option, - sort_by: Option<&str>, - max_quotes: Option, - solana_ephemeral_signer_pubkey: Option<&str>, - ) -> Result { - let slippage_str = slippage_bps.unwrap_or(100).to_string(); - let max_quotes_str = max_quotes.unwrap_or(3).to_string(); - let sort = sort_by.unwrap_or("price"); - - let mut params: Vec<(&str, &str)> = vec![ - ("originChain", origin_chain), - ("destinationChain", destination_chain), - ("sellToken", sell_token), - ("buyToken", buy_token), - ("sellAmount", sell_amount), - ("originAddress", origin_address), - ("destinationAddress", destination_address), - ("slippageBps", &slippage_str), - ("sortQuotesBy", sort), - ("maxNumQuotes", &max_quotes_str), +/// Inputs for `GET /cross-chain/quotes`. Bundled into a struct because the +/// endpoint takes many parameters; a positional signature was a footgun +/// (several adjacent same-typed args). +/// +/// `destination_address` is always sent: the API requires it for any +/// Solana-side leg (the protocol can't derive a Solana pubkey from an EVM +/// signer) and for EVM-to-EVM in some versions. Callers default it to the +/// signer's address on the destination chain. +pub struct CrossChainQuoteParams<'a> { + pub origin_chain: &'a str, + pub destination_chain: &'a str, + pub sell_token: &'a str, + pub buy_token: &'a str, + pub sell_amount: &'a str, + pub origin_address: &'a str, + pub destination_address: &'a str, + pub slippage_bps: Option, + pub sort_by: Option<&'a str>, + pub max_quotes: Option, + pub solana_ephemeral_signer_pubkey: Option<&'a str>, + /// Allow-list of bridge providers; empty → not sent. Mutually exclusive + /// with `excluded_bridges` (enforced at the CLI layer). + pub included_bridges: &'a [String], + /// Block-list of bridge providers; empty → not sent. + pub excluded_bridges: &'a [String], + /// DEX sources to exclude on both legs; empty → not sent. + pub excluded_swap_sources: &'a [String], +} + +impl CrossChainQuoteParams<'_> { + /// Build the query-string pairs for the request. Returns owned values so + /// the caller can borrow them into `&[(&str, &str)]`. Optional filters are + /// only included when non-empty; comma-separated lists are joined here. + fn query_pairs(&self) -> Vec<(&'static str, String)> { + let mut params: Vec<(&'static str, String)> = vec![ + ("originChain", self.origin_chain.to_string()), + ("destinationChain", self.destination_chain.to_string()), + ("sellToken", self.sell_token.to_string()), + ("buyToken", self.buy_token.to_string()), + ("sellAmount", self.sell_amount.to_string()), + ("originAddress", self.origin_address.to_string()), + ("destinationAddress", self.destination_address.to_string()), + ("slippageBps", self.slippage_bps.unwrap_or(100).to_string()), + ("sortQuotesBy", self.sort_by.unwrap_or("price").to_string()), + ("maxNumQuotes", self.max_quotes.unwrap_or(3).to_string()), ]; // Unlocks Solana-origin routes that need a one-shot extra signer // (e.g. CCTP's event account); the holder of the keypair must // co-sign the returned transaction. - if let Some(pubkey) = solana_ephemeral_signer_pubkey { - params.push(("solanaEphemeralSignerPubkey", pubkey)); + if let Some(pubkey) = self.solana_ephemeral_signer_pubkey { + params.push(("solanaEphemeralSignerPubkey", pubkey.to_string())); + } + if !self.included_bridges.is_empty() { + params.push(("includedBridges", self.included_bridges.join(","))); } + if !self.excluded_bridges.is_empty() { + params.push(("excludedBridges", self.excluded_bridges.join(","))); + } + if !self.excluded_swap_sources.is_empty() { + params.push(("excludedSwapSources", self.excluded_swap_sources.join(","))); + } + params + } +} - self.get("/cross-chain/quotes", ¶ms).await +impl ApiClient { + /// Get cross-chain quotes. + pub async fn get_cross_chain_quotes( + &self, + params: &CrossChainQuoteParams<'_>, + ) -> Result { + // `query_pairs` owns its values so we can borrow them into the + // `&[(&str, &str)]` shape `get` expects without lifetime juggling. + let owned = params.query_pairs(); + let borrowed: Vec<(&str, &str)> = owned.iter().map(|(k, v)| (*k, v.as_str())).collect(); + self.get("/cross-chain/quotes", &borrowed).await } /// Get cross-chain status @@ -211,3 +242,80 @@ impl ApiClient { .await } } + +#[cfg(test)] +mod query_pairs_tests { + use super::*; + + /// A params struct with the required fields set and all optional + /// filters empty. Tests override only what they exercise. + fn base<'a>( + included: &'a [String], + excluded: &'a [String], + sources: &'a [String], + ) -> CrossChainQuoteParams<'a> { + CrossChainQuoteParams { + origin_chain: "8453", + destination_chain: "42161", + sell_token: "0xsell", + buy_token: "0xbuy", + sell_amount: "1000000", + origin_address: "0xorigin", + destination_address: "0xdest", + slippage_bps: Some(100), + sort_by: Some("price"), + max_quotes: Some(3), + solana_ephemeral_signer_pubkey: None, + included_bridges: included, + excluded_bridges: excluded, + excluded_swap_sources: sources, + } + } + + fn value_for<'a>(pairs: &'a [(&'static str, String)], key: &str) -> Option<&'a str> { + pairs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.as_str()) + } + + #[test] + fn omits_filter_keys_when_empty() { + let empty: Vec = Vec::new(); + let params = base(&empty, &empty, &empty); + let pairs = params.query_pairs(); + assert!(value_for(&pairs, "includedBridges").is_none()); + assert!(value_for(&pairs, "excludedBridges").is_none()); + assert!(value_for(&pairs, "excludedSwapSources").is_none()); + // Required params are always present. + assert_eq!(value_for(&pairs, "originChain"), Some("8453")); + assert_eq!(value_for(&pairs, "sellAmount"), Some("1000000")); + } + + #[test] + fn joins_excluded_bridges_with_comma() { + let empty: Vec = Vec::new(); + let excluded = vec!["across".to_string(), "stargate".to_string()]; + let params = base(&empty, &excluded, &empty); + let pairs = params.query_pairs(); + assert_eq!( + value_for(&pairs, "excludedBridges"), + Some("across,stargate") + ); + assert!(value_for(&pairs, "includedBridges").is_none()); + } + + #[test] + fn included_bridges_and_swap_sources_sent_when_set() { + let empty: Vec = Vec::new(); + let included = vec!["relay".to_string()]; + let sources = vec!["Uniswap_V3".to_string(), "Curve".to_string()]; + let params = base(&included, &empty, &sources); + let pairs = params.query_pairs(); + assert_eq!(value_for(&pairs, "includedBridges"), Some("relay")); + assert_eq!( + value_for(&pairs, "excludedSwapSources"), + Some("Uniswap_V3,Curve") + ); + } +} diff --git a/src/cli.rs b/src/cli.rs index 65ccbc6..b590a7e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -700,6 +700,21 @@ pub struct CrossChainArgs { /// Maximum number of quotes to fetch (1-10) #[arg(long, default_value = "3")] pub max_quotes: u8, + + /// Only route through these bridge providers (comma-separated, or repeat + /// the flag). Mutually exclusive with --excluded-bridges. + #[arg(long, value_delimiter = ',', conflicts_with = "excluded_bridges")] + pub included_bridges: Vec, + + /// Never route through these bridge providers (comma-separated, or repeat + /// the flag). Mutually exclusive with --included-bridges. + #[arg(long, value_delimiter = ',')] + pub excluded_bridges: Vec, + + /// Exclude these DEX sources from routing on both chains (comma-separated, + /// or repeat the flag). + #[arg(long, value_delimiter = ',')] + pub excluded_swap_sources: Vec, } #[derive(Parser, Debug)] @@ -750,3 +765,74 @@ pub enum StatusType { /// Cross-chain bridge status CrossChain, } + +#[cfg(test)] +mod cross_chain_arg_tests { + use super::*; + use clap::Parser; + + /// The minimal required cross-chain invocation; tests append flags. + fn base_args() -> Vec<&'static str> { + vec![ + "0x", + "cross-chain", + "--from", + "base", + "--to", + "arbitrum", + "--sell", + "0xsell", + "--buy", + "0xbuy", + "--amount", + "1000000", + ] + } + + fn parse_cross_chain(extra: &[&str]) -> Result { + let mut argv = base_args(); + argv.extend_from_slice(extra); + match Cli::try_parse_from(argv)?.command { + Commands::CrossChain(args) => Ok(args), + _ => panic!("expected cross-chain subcommand"), + } + } + + #[test] + fn filters_default_to_empty() { + let args = parse_cross_chain(&[]).unwrap(); + assert!(args.included_bridges.is_empty()); + assert!(args.excluded_bridges.is_empty()); + assert!(args.excluded_swap_sources.is_empty()); + } + + #[test] + fn comma_separated_excluded_bridges_split_into_vec() { + let args = parse_cross_chain(&["--excluded-bridges", "across,stargate"]).unwrap(); + assert_eq!(args.excluded_bridges, vec!["across", "stargate"]); + } + + #[test] + fn repeated_flag_accumulates() { + let args = parse_cross_chain(&[ + "--excluded-swap-sources", + "Curve", + "--excluded-swap-sources", + "Uniswap_V3", + ]) + .unwrap(); + assert_eq!(args.excluded_swap_sources, vec!["Curve", "Uniswap_V3"]); + } + + #[test] + fn included_and_excluded_bridges_are_mutually_exclusive() { + let err = parse_cross_chain(&[ + "--included-bridges", + "relay", + "--excluded-bridges", + "across", + ]) + .unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } +} diff --git a/src/commands/cross_chain/mod.rs b/src/commands/cross_chain/mod.rs index 2690a07..cf0ff5e 100644 --- a/src/commands/cross_chain/mod.rs +++ b/src/commands/cross_chain/mod.rs @@ -65,21 +65,27 @@ pub async fn run( let ephemeral_signer = origin.is_solana().then(solana_sdk::signature::Keypair::new); let ephemeral_signer_pubkey = ephemeral_signer.as_ref().map(|kp| kp.pubkey().to_string()); let spinner = output.spinner_guard("Fetching cross-chain quotes..."); - let quotes_resp = client - .get_cross_chain_quotes( - &origin.api_chain_id(), - &destination.api_chain_id(), - &args.sell, - &args.buy, - &args.amount, - &origin_address, - &destination_address, - Some(args.slippage), - Some(sort_by), - Some(args.max_quotes), - ephemeral_signer_pubkey.as_deref(), - ) - .await?; + // Bind the chain-id strings to locals: `CrossChainQuoteParams` borrows + // them and outlives this statement, so they can't be temporaries. + let origin_chain_id_str = origin.api_chain_id(); + let destination_chain_id_str = destination.api_chain_id(); + let quote_params = crate::api::cross_chain::CrossChainQuoteParams { + origin_chain: &origin_chain_id_str, + destination_chain: &destination_chain_id_str, + sell_token: &args.sell, + buy_token: &args.buy, + sell_amount: &args.amount, + origin_address: &origin_address, + destination_address: &destination_address, + slippage_bps: Some(args.slippage), + sort_by: Some(sort_by), + max_quotes: Some(args.max_quotes), + solana_ephemeral_signer_pubkey: ephemeral_signer_pubkey.as_deref(), + included_bridges: &args.included_bridges, + excluded_bridges: &args.excluded_bridges, + excluded_swap_sources: &args.excluded_swap_sources, + }; + let quotes_resp = client.get_cross_chain_quotes("e_params).await?; drop(spinner); if quotes_resp.quotes.is_empty() || !quotes_resp.liquidity_available {