Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions skills/0x-trade/references/cross-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a,b>` — only route through these bridge providers.
- `--excluded-bridges <a,b>` — never route through these bridge providers.
- `--excluded-swap-sources <a,b>` — 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:
Expand Down
186 changes: 147 additions & 39 deletions src/api/cross_chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
sort_by: Option<&str>,
max_quotes: Option<u8>,
solana_ephemeral_signer_pubkey: Option<&str>,
) -> Result<CrossChainQuotesResponse, CliError> {
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<u32>,
pub sort_by: Option<&'a str>,
pub max_quotes: Option<u8>,
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", &params).await
impl ApiClient {
/// Get cross-chain quotes.
pub async fn get_cross_chain_quotes(
&self,
params: &CrossChainQuoteParams<'_>,
) -> Result<CrossChainQuotesResponse, CliError> {
// `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
Expand All @@ -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<String> = 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<String> = 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<String> = 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")
);
}
}
86 changes: 86 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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<String>,

/// Exclude these DEX sources from routing on both chains (comma-separated,
/// or repeat the flag).
#[arg(long, value_delimiter = ',')]
pub excluded_swap_sources: Vec<String>,
}

#[derive(Parser, Debug)]
Expand Down Expand Up @@ -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<CrossChainArgs, clap::Error> {
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);
}
}
36 changes: 21 additions & 15 deletions src/commands/cross_chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&quote_params).await?;
drop(spinner);

if quotes_resp.quotes.is_empty() || !quotes_resp.liquidity_available {
Expand Down
Loading