From 5d84fd931fb9e8060051cb4dd0aedc05c91afb5e Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 2 Jul 2026 10:50:45 +0200 Subject: [PATCH 1/3] docs: Cross-chain route filters design spec --- ...-07-02-cross-chain-route-filters-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-cross-chain-route-filters-design.md diff --git a/docs/superpowers/specs/2026-07-02-cross-chain-route-filters-design.md b/docs/superpowers/specs/2026-07-02-cross-chain-route-filters-design.md new file mode 100644 index 0000000..42ef935 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-cross-chain-route-filters-design.md @@ -0,0 +1,105 @@ +# Cross-Chain Route Filters — Design + +**Date:** 2026-07-02 +**Command affected:** `0x cross-chain` + +## Motivation + +The 0x Cross-Chain `getQuotes` endpoint supports optional routing filters that +the CLI does not currently expose: + +- `includedBridges` — allow-list of bridge providers. +- `excludedBridges` — block-list of bridge providers (mutually exclusive with + `includedBridges`). +- `excludedSwapSources` — DEX sources to exclude on **both** legs. + +Without these, `0x cross-chain` users cannot pin a preferred bridge or steer +away from a route they distrust. This work adds the three filters as CLI flags. + +**Explicitly out of scope** (deferred): custom recipient (`destinationAddress` +override), integrator fees (`feeBps`/`feeRecipient`/`feeToken`), and Solana +`gasPayer`. `destinationAddress` and `solanaEphemeralSignerPubkey` are already +sent by the CLI (auto-resolved / auto-generated) and are not gaps. + +## CLI surface + +Three new optional flags on `CrossChainArgs`: + +| Flag | API param | Meaning | +|------|-----------|---------| +| `--included-bridges ` | `includedBridges` | Only route through these bridges. | +| `--excluded-bridges ` | `excludedBridges` | Never route through these bridges. | +| `--excluded-swap-sources ` | `excludedSwapSources` | Exclude these DEX sources on both legs. | + +Semantics: + +- Each flag is `Vec` with clap `value_delimiter = ','`. Both + `--excluded-bridges across,stargate` and repeated + `--excluded-bridges across --excluded-bridges stargate` work. Omitted → empty + `Vec` → the query param is **not sent**. +- `--included-bridges` and `--excluded-bridges` are **mutually exclusive**, + enforced at the clap layer via `conflicts_with`. Supplying both fails as an + input error (**exit 2**) with a clear message, rather than a round-trip API + rejection. `--excluded-swap-sources` is independent and combines with either. +- **No client-side validation** of bridge/source names. The provider list + drifts server-side, so unknown names pass through and the API decides. This + matches how the CLI already treats token/chain identifiers at its edges. + +## Plumbing & API signature refactor + +`get_cross_chain_quotes` already carries 11 positional args under +`#[allow(clippy::too_many_arguments)]`. Adding three more same-typed CSV params +would push it to 14 and make the call site a footgun (three adjacent +`Option<&str>`). As a targeted improvement scoped to this work: + +- Introduce `CrossChainQuoteParams<'a>` in `src/api/cross_chain.rs` holding the + request inputs: origin/destination chain, sell/buy token, amount, origin and + destination addresses, slippage, sort, max_quotes, ephemeral signer pubkey, + **plus the three new filters**. +- `get_cross_chain_quotes` becomes `&self, params: &CrossChainQuoteParams<'_>` + and drops the `too_many_arguments` allow. +- Query construction is unchanged in spirit: push + `("includedBridges", csv)` / `("excludedBridges", csv)` / + `("excludedSwapSources", csv)` only when the corresponding `Vec` is + non-empty, joining elements with `","`. The joined `String`s live in locals + (like the existing `slippage_str`) so the `&str` borrows stay valid. +- `commands/cross_chain/mod.rs::run` joins `args.included_bridges` etc. and + populates the struct. No change to quote selection, execution, allowance, or + polling. + +The struct is deliberately scoped to this one endpoint — no other command +shares these params, so a shared request-builder would be premature (YAGNI). + +## Testing + +Following the existing inline `tron_wiring_tests` module and +`tests/cli_output.rs` patterns: + +- **Arg parsing** — `--excluded-bridges across,stargate` parses to a 2-element + `Vec`; repeated flags accumulate; omitted → empty `Vec`. +- **Mutual exclusion** — `--included-bridges` + `--excluded-bridges` together + fails clap parsing (exit 2), asserted via `try_parse_from`. +- **Query construction** — build `CrossChainQuoteParams` with and without each + filter; assert the query vector contains `("excludedBridges","across,stargate")` + when set and omits the key entirely when the `Vec` is empty. Network-free; + this is the behavior that matters. + +No new live-API/integration test — the filters are pure passthrough. The +`test-cross-chain` skill covers manual staging verification if desired. + +## Docs + +- `0x cross-chain --help` updates automatically from clap doc-comments (written + as flag help text). +- Update the `0x-trade` skill reference `references/cross-chain.md` with a + "Route filtering" subsection and the mutual-exclusivity note. +- README: add the three flags only if it enumerates cross-chain flags; if it + only shows examples, leave it. + +## Error handling + +No new error codes. + +- Mutual exclusion → clap exit 2 (input error). +- Unknown bridge/source names → existing API error / `NO_LIQUIDITY` paths, which + already handle empty result sets. From b744a4ecbeeec67a5f1f89540f84b16e7aa51001 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 2 Jul 2026 11:56:43 +0200 Subject: [PATCH 2/3] feat: Cross-chain route filters (included/excluded bridges, excluded swap sources) Add --included-bridges, --excluded-bridges, and --excluded-swap-sources flags to `0x cross-chain`, wiring the includedBridges/excludedBridges/ excludedSwapSources params of the cross-chain getQuotes endpoint. - CSV or repeated flags (clap value_delimiter); omitted -> param not sent. - --included-bridges and --excluded-bridges are mutually exclusive (clap conflicts_with -> input error, exit 2). - No client-side name validation; unknown names pass through to the API. Refactors get_cross_chain_quotes onto a CrossChainQuoteParams struct (the positional signature had hit too_many_arguments) with a testable query_pairs builder. Adds arg-parsing and query-construction unit tests and documents the flags in the cross-chain skill reference. --- skills/0x-trade/references/cross-chain.md | 18 +++ src/api/cross_chain.rs | 186 +++++++++++++++++----- src/cli.rs | 86 ++++++++++ src/commands/cross_chain/mod.rs | 36 +++-- 4 files changed, 272 insertions(+), 54 deletions(-) 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 { From 2b43813d274829357b4a2b55c845f8bf716df504 Mon Sep 17 00:00:00 2001 From: Wojciech Date: Thu, 2 Jul 2026 13:17:46 +0200 Subject: [PATCH 3/3] chore: Remove design spec from PR --- ...-07-02-cross-chain-route-filters-design.md | 105 ------------------ 1 file changed, 105 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-02-cross-chain-route-filters-design.md diff --git a/docs/superpowers/specs/2026-07-02-cross-chain-route-filters-design.md b/docs/superpowers/specs/2026-07-02-cross-chain-route-filters-design.md deleted file mode 100644 index 42ef935..0000000 --- a/docs/superpowers/specs/2026-07-02-cross-chain-route-filters-design.md +++ /dev/null @@ -1,105 +0,0 @@ -# Cross-Chain Route Filters — Design - -**Date:** 2026-07-02 -**Command affected:** `0x cross-chain` - -## Motivation - -The 0x Cross-Chain `getQuotes` endpoint supports optional routing filters that -the CLI does not currently expose: - -- `includedBridges` — allow-list of bridge providers. -- `excludedBridges` — block-list of bridge providers (mutually exclusive with - `includedBridges`). -- `excludedSwapSources` — DEX sources to exclude on **both** legs. - -Without these, `0x cross-chain` users cannot pin a preferred bridge or steer -away from a route they distrust. This work adds the three filters as CLI flags. - -**Explicitly out of scope** (deferred): custom recipient (`destinationAddress` -override), integrator fees (`feeBps`/`feeRecipient`/`feeToken`), and Solana -`gasPayer`. `destinationAddress` and `solanaEphemeralSignerPubkey` are already -sent by the CLI (auto-resolved / auto-generated) and are not gaps. - -## CLI surface - -Three new optional flags on `CrossChainArgs`: - -| Flag | API param | Meaning | -|------|-----------|---------| -| `--included-bridges ` | `includedBridges` | Only route through these bridges. | -| `--excluded-bridges ` | `excludedBridges` | Never route through these bridges. | -| `--excluded-swap-sources ` | `excludedSwapSources` | Exclude these DEX sources on both legs. | - -Semantics: - -- Each flag is `Vec` with clap `value_delimiter = ','`. Both - `--excluded-bridges across,stargate` and repeated - `--excluded-bridges across --excluded-bridges stargate` work. Omitted → empty - `Vec` → the query param is **not sent**. -- `--included-bridges` and `--excluded-bridges` are **mutually exclusive**, - enforced at the clap layer via `conflicts_with`. Supplying both fails as an - input error (**exit 2**) with a clear message, rather than a round-trip API - rejection. `--excluded-swap-sources` is independent and combines with either. -- **No client-side validation** of bridge/source names. The provider list - drifts server-side, so unknown names pass through and the API decides. This - matches how the CLI already treats token/chain identifiers at its edges. - -## Plumbing & API signature refactor - -`get_cross_chain_quotes` already carries 11 positional args under -`#[allow(clippy::too_many_arguments)]`. Adding three more same-typed CSV params -would push it to 14 and make the call site a footgun (three adjacent -`Option<&str>`). As a targeted improvement scoped to this work: - -- Introduce `CrossChainQuoteParams<'a>` in `src/api/cross_chain.rs` holding the - request inputs: origin/destination chain, sell/buy token, amount, origin and - destination addresses, slippage, sort, max_quotes, ephemeral signer pubkey, - **plus the three new filters**. -- `get_cross_chain_quotes` becomes `&self, params: &CrossChainQuoteParams<'_>` - and drops the `too_many_arguments` allow. -- Query construction is unchanged in spirit: push - `("includedBridges", csv)` / `("excludedBridges", csv)` / - `("excludedSwapSources", csv)` only when the corresponding `Vec` is - non-empty, joining elements with `","`. The joined `String`s live in locals - (like the existing `slippage_str`) so the `&str` borrows stay valid. -- `commands/cross_chain/mod.rs::run` joins `args.included_bridges` etc. and - populates the struct. No change to quote selection, execution, allowance, or - polling. - -The struct is deliberately scoped to this one endpoint — no other command -shares these params, so a shared request-builder would be premature (YAGNI). - -## Testing - -Following the existing inline `tron_wiring_tests` module and -`tests/cli_output.rs` patterns: - -- **Arg parsing** — `--excluded-bridges across,stargate` parses to a 2-element - `Vec`; repeated flags accumulate; omitted → empty `Vec`. -- **Mutual exclusion** — `--included-bridges` + `--excluded-bridges` together - fails clap parsing (exit 2), asserted via `try_parse_from`. -- **Query construction** — build `CrossChainQuoteParams` with and without each - filter; assert the query vector contains `("excludedBridges","across,stargate")` - when set and omits the key entirely when the `Vec` is empty. Network-free; - this is the behavior that matters. - -No new live-API/integration test — the filters are pure passthrough. The -`test-cross-chain` skill covers manual staging verification if desired. - -## Docs - -- `0x cross-chain --help` updates automatically from clap doc-comments (written - as flag help text). -- Update the `0x-trade` skill reference `references/cross-chain.md` with a - "Route filtering" subsection and the mutual-exclusivity note. -- README: add the three flags only if it enumerates cross-chain flags; if it - only shows examples, leave it. - -## Error handling - -No new error codes. - -- Mutual exclusion → clap exit 2 (input error). -- Unknown bridge/source names → existing API error / `NO_LIQUIDITY` paths, which - already handle empty result sets.