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 {