diff --git a/README.md b/README.md index 120f350..c4414e0 100644 --- a/README.md +++ b/README.md @@ -258,8 +258,18 @@ No gas fees required. The 0x protocol handles gas on your behalf. # Sort by fastest bridge 0x cross-chain --from base --to arbitrum \ --sell 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --buy 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 --amount 1000000 --sort speed + +# Stream quotes as they're discovered (SSE) +0x cross-chain --from base --to arbitrum \ + --sell 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --buy 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 --amount 1000000 --stream ``` +`--stream` fetches quotes progressively via Server-Sent Events instead of +waiting for all bridges to respond. Progress appears on stderr; the final +output is identical to the non-streaming mode, with quotes re-sorted best +price first before selection. Cannot be combined with `--sort`; `--max-quotes` +defaults to 5 (1-10) instead of 3. + Solana-origin swaps automatically include routes that need an extra one-shot transaction signer (e.g. Circle CCTP): the CLI generates a fresh keypair per quote request, sends its pubkey with the quote, and co-signs at submission. diff --git a/src/api/cross_chain.rs b/src/api/cross_chain.rs index adadc38..a4992ac 100644 --- a/src/api/cross_chain.rs +++ b/src/api/cross_chain.rs @@ -90,6 +90,134 @@ pub struct CrossChainAllowance { pub spender: String, } +/// One decoded SSE message from `GET /cross-chain/quotes/stream`. Each `data:` +/// line is a JSON object of this shape: a `zid` correlation id plus a tagged +/// `event`. +#[derive(Debug, Clone, Deserialize)] +pub struct CrossChainStreamMessage { + #[serde(default)] + pub zid: Option, + pub event: CrossChainStreamEvent, +} + +/// The `event` payload, discriminated by its inner `type` field. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum CrossChainStreamEvent { + /// A discovered quote. Arrives zero or more times before the terminal + /// `result`. Boxed to keep the enum small — the quote payload dwarfs the + /// other variants. + Quote { + data: Box, + }, + /// Terminal event: the stream is done. `liquidityAvailable` mirrors the + /// buffered response's field. + Result { data: CrossChainStreamResultData }, + /// Terminal error event. + Error { data: CrossChainStreamErrorData }, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CrossChainStreamQuoteData { + pub quote: CrossChainQuote, + #[serde(default)] + pub allowance_target: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CrossChainStreamResultData { + #[serde(default)] + pub liquidity_available: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CrossChainStreamErrorData { + #[serde(default)] + pub message: Option, + #[serde(default)] + pub code: Option, +} + +/// Incremental Server-Sent Events parser (WHATWG subset). Feed raw byte +/// chunks as they arrive off the wire; complete events are dispatched at the +/// blank-line boundary, with consecutive `data:` lines joined by `\n` per the +/// spec. `\r\n`, `\n`, and bare `\r` all terminate lines — including a `\r\n` +/// pair split across two chunks. Comments (`:`-prefixed) and non-`data` +/// fields (`event:`, `id:`, `retry:`) are ignored — the streaming quotes +/// endpoint carries everything in `data:`. +/// +/// Bytes are buffered raw and only decoded once a line is complete, so a +/// multi-byte UTF-8 sequence split across two chunks is never mangled. +#[derive(Default)] +pub struct SseParser { + line: Vec, + data_lines: Vec, + /// The previous byte was a bare `\r` line terminator; a `\n` immediately + /// after belongs to the same `\r\n` pair and must be swallowed. + skip_next_lf: bool, +} + +impl SseParser { + pub fn new() -> Self { + Self::default() + } + + /// Feed a chunk of bytes; returns the joined `data:` payload of every + /// event completed (blank-line-terminated) by this chunk. + pub fn feed(&mut self, chunk: &[u8]) -> Vec { + let mut out = Vec::new(); + for &b in chunk { + if std::mem::take(&mut self.skip_next_lf) && b == b'\n' { + continue; + } + match b { + b'\n' => self.end_line(&mut out), + b'\r' => { + self.end_line(&mut out); + self.skip_next_lf = true; + } + _ => self.line.push(b), + } + } + out + } + + /// Flush at end-of-stream: a final event whose terminating blank line + /// never arrived is returned rather than silently dropped. + pub fn finish(&mut self) -> Option { + if !self.line.is_empty() { + // Complete the dangling line. Only a blank line dispatches an + // event, so `out` stays empty here; any `data:` content lands in + // `data_lines`. + let mut out = Vec::new(); + self.end_line(&mut out); + } + if self.data_lines.is_empty() { + None + } else { + Some(std::mem::take(&mut self.data_lines).join("\n")) + } + } + + fn end_line(&mut self, out: &mut Vec) { + let line = String::from_utf8_lossy(&self.line).into_owned(); + self.line.clear(); + if line.is_empty() { + // Event boundary: dispatch accumulated data, if any. + if !self.data_lines.is_empty() { + out.push(std::mem::take(&mut self.data_lines).join("\n")); + } + } else if let Some(rest) = line.strip_prefix("data:") { + // Honor the optional single space after the colon per the spec. + self.data_lines + .push(rest.strip_prefix(' ').unwrap_or(rest).to_string()); + } + // Comments (`:`-prefixed) and other fields are ignored. + } +} + /// Response from GET /cross-chain/status #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -178,10 +306,25 @@ pub struct CrossChainQuoteParams<'a> { } 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. + /// Build the query-string pairs for the buffered `/cross-chain/quotes` + /// request. Returns owned values so the caller can borrow them into + /// `&[(&str, &str)]`. fn query_pairs(&self) -> Vec<(&'static str, String)> { + self.build_query_pairs(true) + } + + /// Build the query-string pairs for the `/cross-chain/quotes/stream` + /// request. Identical to [`Self::query_pairs`] except `sortQuotesBy` is + /// omitted — the streaming endpoint doesn't support it (quotes arrive in + /// discovery order). + fn stream_query_pairs(&self) -> Vec<(&'static str, String)> { + self.build_query_pairs(false) + } + + /// Shared query-pair builder. `include_sort` gates `sortQuotesBy`, the one + /// parameter the streaming endpoint rejects. Optional filters are only + /// included when non-empty; comma-separated lists are joined here. + fn build_query_pairs(&self, include_sort: bool) -> Vec<(&'static str, String)> { let mut params: Vec<(&'static str, String)> = vec![ ("originChain", self.origin_chain.to_string()), ("destinationChain", self.destination_chain.to_string()), @@ -191,9 +334,11 @@ impl CrossChainQuoteParams<'_> { ("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()), ]; + if include_sort { + params.push(("sortQuotesBy", self.sort_by.unwrap_or("price").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. @@ -226,6 +371,19 @@ impl ApiClient { self.get("/cross-chain/quotes", &borrowed).await } + /// Open the cross-chain quotes SSE stream. Returns the raw response for + /// chunked reading; the caller drives [`SseParser`] and decodes each + /// `data:` payload into a [`CrossChainStreamMessage`]. + pub async fn open_cross_chain_quotes_stream( + &self, + params: &CrossChainQuoteParams<'_>, + ) -> Result { + let owned = params.stream_query_pairs(); + let borrowed: Vec<(&str, &str)> = owned.iter().map(|(k, v)| (*k, v.as_str())).collect(); + self.get_stream("/cross-chain/quotes/stream", &borrowed) + .await + } + /// Get cross-chain status pub async fn get_cross_chain_status( &self, @@ -318,4 +476,181 @@ mod query_pairs_tests { Some("Uniswap_V3,Curve") ); } + + #[test] + fn stream_query_pairs_omits_sort() { + let empty: Vec = Vec::new(); + let params = base(&empty, &empty, &empty); + let pairs = params.stream_query_pairs(); + assert!(value_for(&pairs, "sortQuotesBy").is_none()); + // Everything else the buffered request sends is still present. + assert_eq!(value_for(&pairs, "maxNumQuotes"), Some("3")); + assert_eq!(value_for(&pairs, "originChain"), Some("8453")); + } +} + +#[cfg(test)] +mod sse_tests { + use super::*; + + /// A minimal but structurally complete quote `data:` payload. + fn quote_json(buy: &str) -> String { + format!( + r#"{{"zid":"z1","event":{{"type":"quote","data":{{"quote":{{"sellAmount":"1000","buyAmount":"{buy}","minBuyAmount":"900","steps":[{{"type":"bridge","provider":"across"}}],"transaction":{{"chainType":"evm","details":{{"to":"0xabc","data":"0x"}}}},"gasCosts":null,"issues":null,"estimatedTimeSeconds":42,"quoteId":"q1"}},"allowanceTarget":"0xspender"}}}}}}"# + ) + } + + #[test] + fn parses_multiple_events_in_one_chunk() { + let mut parser = SseParser::new(); + let chunk = format!( + "data: {}\n\ndata: {}\n\n", + quote_json("100"), + quote_json("200") + ); + let payloads = parser.feed(chunk.as_bytes()); + assert_eq!(payloads.len(), 2); + // The leading space after `data:` is stripped, leaving raw JSON. + assert!(payloads[0].starts_with('{')); + } + + #[test] + fn reassembles_event_split_across_chunks() { + let mut parser = SseParser::new(); + let full = format!("data: {}\n\n", quote_json("100")); + let split = full.len() / 2; + // No complete event yet — nothing emitted. + assert!(parser.feed(&full.as_bytes()[..split]).is_empty()); + let payloads = parser.feed(&full.as_bytes()[split..]); + assert_eq!(payloads.len(), 1); + let msg: CrossChainStreamMessage = serde_json::from_str(&payloads[0]).unwrap(); + assert!(matches!(msg.event, CrossChainStreamEvent::Quote { .. })); + } + + #[test] + fn joins_consecutive_data_lines_with_newline() { + // Per the WHATWG spec, one event may span several `data:` lines; the + // dispatched payload joins them with `\n`. + let mut parser = SseParser::new(); + let payloads = parser.feed(b"data: part1\ndata: part2\n\n"); + assert_eq!(payloads, vec!["part1\npart2".to_string()]); + } + + #[test] + fn handles_crlf_line_terminators() { + let mut parser = SseParser::new(); + let payloads = parser.feed(b"data: X\r\n\r\ndata: Y\r\n\r\n"); + assert_eq!(payloads, vec!["X".to_string(), "Y".to_string()]); + } + + #[test] + fn handles_bare_cr_line_terminators() { + let mut parser = SseParser::new(); + let payloads = parser.feed(b"data: X\r\rdata: Y\r\r"); + assert_eq!(payloads, vec!["X".to_string(), "Y".to_string()]); + } + + #[test] + fn handles_crlf_split_across_chunks() { + // A `\r` at the end of one chunk followed by `\n` at the start of the + // next is one terminator, not two. + let mut parser = SseParser::new(); + assert!(parser.feed(b"data: X\r").is_empty()); + let payloads = parser.feed(b"\n\r\n"); + assert_eq!(payloads, vec!["X".to_string()]); + } + + #[test] + fn finish_flushes_pending_event_at_end_of_stream() { + // The terminating blank line never arrived; end-of-stream still + // yields the accumulated data instead of dropping it. + let mut parser = SseParser::new(); + assert!(parser.feed(b"data: X\n").is_empty()); + assert_eq!(parser.finish().as_deref(), Some("X")); + // A second finish is a no-op. + assert!(parser.finish().is_none()); + } + + #[test] + fn finish_flushes_dangling_unterminated_line() { + let mut parser = SseParser::new(); + assert!(parser.feed(b"data: X").is_empty()); + assert_eq!(parser.finish().as_deref(), Some("X")); + } + + #[test] + fn ignores_comments_blank_lines_and_other_fields() { + let mut parser = SseParser::new(); + let chunk = format!( + ": keep-alive comment\n\nevent: quote\nid: 7\ndata: {}\n\n", + quote_json("100") + ); + let payloads = parser.feed(chunk.as_bytes()); + // Only the `data:` line contributes to the dispatched payload. + assert_eq!(payloads.len(), 1); + assert!(payloads[0].starts_with('{')); + } + + // A `data:` payload whose JSON is malformed is deliberately *skipped* by + // the consuming loop rather than aborting the stream: the parser still + // emits the raw payload string, but decoding it fails and the caller + // drops it. This test pins that the parser itself is lossless (emits the + // payload) and that decode surfaces an error the caller can choose to + // ignore. + #[test] + fn malformed_json_payload_is_emitted_but_fails_to_decode() { + let mut parser = SseParser::new(); + let payloads = parser.feed(b"data: {not valid json}\n\n"); + assert_eq!(payloads.len(), 1); + assert!(serde_json::from_str::(&payloads[0]).is_err()); + } + + #[test] + fn deserializes_quote_event() { + let msg: CrossChainStreamMessage = serde_json::from_str("e_json("12345")).unwrap(); + assert_eq!(msg.zid.as_deref(), Some("z1")); + match msg.event { + CrossChainStreamEvent::Quote { data } => { + assert_eq!(data.quote.buy_amount, "12345"); + assert_eq!(data.allowance_target.as_deref(), Some("0xspender")); + assert_eq!(data.quote.bridge_provider(), "across"); + } + _ => panic!("expected quote event"), + } + } + + #[test] + fn deserializes_result_event() { + let json = r#"{"zid":"z2","event":{"type":"result","data":{"liquidityAvailable":true}}}"#; + let msg: CrossChainStreamMessage = serde_json::from_str(json).unwrap(); + match msg.event { + CrossChainStreamEvent::Result { data } => assert!(data.liquidity_available), + _ => panic!("expected result event"), + } + } + + #[test] + fn deserializes_result_event_with_empty_data() { + // A terminal `result` missing `liquidityAvailable` still parses; + // assemble() then infers availability from the collected quotes. + let json = r#"{"zid":"z2","event":{"type":"result","data":{}}}"#; + let msg: CrossChainStreamMessage = serde_json::from_str(json).unwrap(); + match msg.event { + CrossChainStreamEvent::Result { data } => assert!(!data.liquidity_available), + _ => panic!("expected result event"), + } + } + + #[test] + fn deserializes_error_event() { + let json = r#"{"zid":"z3","event":{"type":"error","data":{"message":"boom","code":"NO_LIQUIDITY"}}}"#; + let msg: CrossChainStreamMessage = serde_json::from_str(json).unwrap(); + match msg.event { + CrossChainStreamEvent::Error { data } => { + assert_eq!(data.message.as_deref(), Some("boom")); + assert_eq!(data.code.as_deref(), Some("NO_LIQUIDITY")); + } + _ => panic!("expected error event"), + } + } } diff --git a/src/api/mod.rs b/src/api/mod.rs index 77e1ce4..e5fad2b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -25,6 +25,14 @@ pub const BASE_URL: &str = "https://api.0x.org"; /// raw instruction byte arrays that otherwise spam the user's terminal. const PARSE_ERROR_BODY_MAX: usize = 512; +/// Floor for the per-request timeout on the SSE streaming endpoints. The +/// client-wide `timeout` (default 30s) covers the whole request including body +/// read, which would abort a stream that legitimately stays open until the +/// server's own ~30s cutoff. Stream requests use `max(configured timeout, +/// this floor)` so a larger `--timeout` is honored but the default can't kill +/// a healthy stream mid-read. +const STREAM_TIMEOUT_FLOOR_SECS: u64 = 45; + /// Walk a 0x error response `data` object for a nested error code name. The v2 /// API has historically used top-level `name`, but older / nested validation /// responses surface the actual cause inside `data.details[].code`, @@ -66,7 +74,7 @@ pub fn truncate_for_error(body: &str) -> String { /// endpoint-specific suggestions (e.g. point users at support when their plan /// doesn't include Solana or cross-chain access). #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EndpointKind { +pub(crate) enum EndpointKind { EvmSwap, Gasless, Solana, @@ -100,6 +108,62 @@ impl EndpointKind { } } +/// Map a 0x API error name (top-level `name` or a nested `data.*.code`) to a +/// typed error code. Shared by the buffered error mapper (`map_api_error`) +/// and the cross-chain SSE stream's terminal `error` events so both paths +/// classify identically. Returns `None` for unrecognized names so callers can +/// apply their own status-aware fallback. +pub(crate) fn error_code_for_api_name(name: &str) -> Option { + match name { + "INSUFFICIENT_BALANCE" | "INSUFFICIENT_BALANCE_OR_ALLOWANCE" => { + Some(ErrorCode::InsufficientBalance) + } + "INSUFFICIENT_ASSET_LIQUIDITY" | "NO_LIQUIDITY" => Some(ErrorCode::NoLiquidity), + "INSUFFICIENT_ALLOWANCE" => Some(ErrorCode::InsufficientAllowance), + "INPUT_INVALID" | "SWAP_VALIDATION_FAILED" => Some(ErrorCode::InputInvalid), + "RECIPIENT_NOT_SUPPORTED" + | "TOKEN_NOT_SUPPORTED" + | "SELL_TOKEN_NOT_AUTHORIZED_FOR_TRADE" + | "BUY_TOKEN_NOT_AUTHORIZED_FOR_TRADE" => Some(ErrorCode::TokenNotSupported), + "SELL_AMOUNT_TOO_SMALL" => Some(ErrorCode::SellAmountTooSmall), + "USER_NOT_AUTHORIZED" | "TAKER_NOT_AUTHORIZED_FOR_TRADE" => { + Some(ErrorCode::InvalidSignature) + } + _ => None, + } +} + +/// Suggestion line for a mapped API error code. Shared by `map_api_error` and +/// the SSE stream's terminal `error` events so streaming errors carry the same +/// guidance as buffered ones. +pub(crate) fn suggestion_for_error_code(code: ErrorCode, endpoint: EndpointKind) -> Option { + match code { + ErrorCode::InsufficientBalance => { + Some("Fund your wallet with more tokens or reduce the amount".into()) + } + ErrorCode::InsufficientAllowance => Some( + "Run the swap with --approval exact (or unlimited) so the CLI approves the spender automatically".into(), + ), + ErrorCode::NoLiquidity => { + Some("Try a different token pair, a smaller amount, or a different chain".into()) + } + ErrorCode::ApiKeyMissing => { + Some("Set your API key with '0x config set api_key '".into()) + } + ErrorCode::ApiAccessDenied => { + let product = endpoint.entitlement_label().unwrap_or("this endpoint"); + Some(format!( + "If your API key is correct, you might not have access to {product}. Contact 0x support: {SUPPORT_URL}" + )) + } + ErrorCode::TokenNotSupported => { + Some("Check the token address is correct for this chain".into()) + } + ErrorCode::SellAmountTooSmall => Some("Increase the sell amount".into()), + _ => None, + } +} + /// Retry policy for the 0x HTTP client. Matches the team's /// `Retry429Strategy` (apps-rs/fee-wallets-ops/src/reqwest_utils.rs) but /// widens the retry set to include 408 (Request Timeout), 425 (Too Early), @@ -192,6 +256,9 @@ fn map_send_error(e: &reqwest_middleware::Error) -> CliError { pub struct ApiClient { client: ClientWithMiddleware, base_url: String, + /// The configured client-wide timeout, kept so streaming requests can + /// compute their per-request override (`max(timeout, stream floor)`). + timeout_secs: u64, } impl ApiClient { @@ -230,6 +297,7 @@ impl ApiClient { Ok(Self { client, base_url: BASE_URL.to_string(), + timeout_secs, }) } @@ -274,7 +342,64 @@ impl ApiClient { let endpoint = EndpointKind::from_path(path); let response = req.send().await.map_err(|e| map_send_error(&e))?; + let response = self.check_response_status(endpoint, response).await?; + let code = response.status().as_u16(); + // Success — parse response body. + let body = response.text().await.map_err(|e| CliError::Api { + code: ErrorCode::NetworkError, + message: format!("Failed to read response body: {e}"), + status: Some(code), + details: None, + suggestion: None, + })?; + + serde_json::from_str::(&body).map_err(|e| CliError::Api { + code: ErrorCode::ApiError, + message: format!("Failed to parse API response: {e}"), + status: Some(code), + details: Some(serde_json::json!({ "body_preview": truncate_for_error(&body) })), + suggestion: None, + }) + } + + /// Open a GET request for chunked/streaming reads (SSE). Retries on + /// transient failures via the middleware, maps any non-2xx through the + /// same path as `send`, and on success hands back the raw + /// `reqwest::Response` so the caller can drive `Response::chunk` itself. + /// The per-request timeout is the configured client timeout raised to the + /// streaming floor, so a long-lived stream isn't cut short mid-read but a + /// larger `--timeout` still applies. + #[tracing::instrument(skip(self, params), fields(path = path, status))] + pub async fn get_stream( + &self, + path: &str, + params: &[(&str, &str)], + ) -> Result { + let endpoint = EndpointKind::from_path(path); + let url = format!("{}{}", self.base_url, path); + let req = self + .client + .get(&url) + .query(params) + .timeout(Duration::from_secs( + self.timeout_secs.max(STREAM_TIMEOUT_FLOOR_SECS), + )); + let response = req.send().await.map_err(|e| map_send_error(&e))?; + self.check_response_status(endpoint, response).await + } + + /// Map any non-2xx HTTP response to a typed `CliError`, reading the (JSON) + /// error body only in the failure case. On 2xx the response is returned + /// untouched so the caller can consume the body however it likes — + /// buffered (`send`) or chunked (`get_stream`). Keeping the status→error + /// mapping in one place is what lets the streaming path surface the exact + /// same entitlement-aware errors as the buffered path. + async fn check_response_status( + &self, + endpoint: EndpointKind, + response: reqwest::Response, + ) -> Result { let status = response.status(); let code = status.as_u16(); tracing::Span::current().record("status", code); @@ -352,22 +477,7 @@ impl ApiClient { return Err(self.map_api_error(endpoint, code, &body)); } - // Success — parse response body. - let body = response.text().await.map_err(|e| CliError::Api { - code: ErrorCode::NetworkError, - message: format!("Failed to read response body: {e}"), - status: Some(code), - details: None, - suggestion: None, - })?; - - serde_json::from_str::(&body).map_err(|e| CliError::Api { - code: ErrorCode::ApiError, - message: format!("Failed to parse API response: {e}"), - status: Some(code), - details: Some(serde_json::json!({ "body_preview": truncate_for_error(&body) })), - suggestion: None, - }) + Ok(response) } /// Map a 4xx API error response to a CliError with the appropriate error code. @@ -404,30 +514,13 @@ impl ApiClient { let (code, message, details) = if let Some(ref err) = parsed { let nested_name = err.data.as_ref().and_then(extract_nested_name); let primary_name = err.name.as_deref().or(nested_name.as_deref()); - let code = match primary_name { - Some("INSUFFICIENT_BALANCE") => ErrorCode::InsufficientBalance, - Some("INSUFFICIENT_BALANCE_OR_ALLOWANCE") => ErrorCode::InsufficientBalance, - Some("INSUFFICIENT_ASSET_LIQUIDITY") | Some("NO_LIQUIDITY") => { - ErrorCode::NoLiquidity - } - Some("INSUFFICIENT_ALLOWANCE") => ErrorCode::InsufficientAllowance, - Some("INPUT_INVALID") | Some("SWAP_VALIDATION_FAILED") => ErrorCode::InputInvalid, - Some("RECIPIENT_NOT_SUPPORTED") => ErrorCode::TokenNotSupported, - Some("TOKEN_NOT_SUPPORTED") - | Some("SELL_TOKEN_NOT_AUTHORIZED_FOR_TRADE") - | Some("BUY_TOKEN_NOT_AUTHORIZED_FOR_TRADE") => ErrorCode::TokenNotSupported, - Some("SELL_AMOUNT_TOO_SMALL") => ErrorCode::SellAmountTooSmall, - Some("USER_NOT_AUTHORIZED") | Some("TAKER_NOT_AUTHORIZED_FOR_TRADE") => { - ErrorCode::InvalidSignature - } - _ => { - if status == 401 || status == 403 || status == 451 { - auth_default - } else { - ErrorCode::ApiError - } - } - }; + let code = primary_name.and_then(error_code_for_api_name).unwrap_or( + if status == 401 || status == 403 || status == 451 { + auth_default + } else { + ErrorCode::ApiError + }, + ); let msg = err .message @@ -449,31 +542,7 @@ impl ApiClient { ) }; - let suggestion = match code { - ErrorCode::InsufficientBalance => { - Some("Fund your wallet with more tokens or reduce the amount".into()) - } - ErrorCode::InsufficientAllowance => Some( - "Run the swap with --approval exact (or unlimited) so the CLI approves the spender automatically".into(), - ), - ErrorCode::NoLiquidity => { - Some("Try a different token pair, a smaller amount, or a different chain".into()) - } - ErrorCode::ApiKeyMissing => { - Some("Set your API key with '0x config set api_key '".into()) - } - ErrorCode::ApiAccessDenied => { - let product = endpoint.entitlement_label().unwrap_or("this endpoint"); - Some(format!( - "If your API key is correct, you might not have access to {product}. Contact 0x support: {SUPPORT_URL}" - )) - } - ErrorCode::TokenNotSupported => { - Some("Check the token address is correct for this chain".into()) - } - ErrorCode::SellAmountTooSmall => Some("Increase the sell amount".into()), - _ => None, - }; + let suggestion = suggestion_for_error_code(code, endpoint); CliError::Api { code, diff --git a/src/cli.rs b/src/cli.rs index b590a7e..6e0eefa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -697,9 +697,17 @@ pub struct CrossChainArgs { #[arg(long)] pub select_quote: Option, - /// Maximum number of quotes to fetch (1-10) - #[arg(long, default_value = "3")] - pub max_quotes: u8, + /// Maximum number of quotes to fetch (1-10). Defaults to 3, or 5 with + /// --stream. + #[arg(long, value_parser = clap::value_parser!(u8).range(1..=10))] + pub max_quotes: Option, + + /// Stream quotes progressively via Server-Sent Events as they are + /// discovered, instead of waiting for one buffered response. Cannot be + /// combined with --sort (the streaming endpoint returns quotes in + /// discovery order). + #[arg(long, conflicts_with = "sort")] + pub stream: bool, /// Only route through these bridge providers (comma-separated, or repeat /// the flag). Mutually exclusive with --excluded-bridges. diff --git a/src/commands/cross_chain/mod.rs b/src/commands/cross_chain/mod.rs index cf0ff5e..a3705b1 100644 --- a/src/commands/cross_chain/mod.rs +++ b/src/commands/cross_chain/mod.rs @@ -4,6 +4,7 @@ mod output; mod select; +mod stream; pub use output::{CrossChainDryRunOutput, CrossChainOutput, DryRunQuote, DryRunStep}; @@ -57,6 +58,8 @@ pub async fn run( QuoteSort::Speed => "speed", }; + let max_quotes = resolve_max_quotes(args.stream, args.max_quotes); + // Step 1: Get quotes // Some Solana-origin routes (e.g. Circle CCTP) need a one-shot extra // signer whose keypair the caller holds; generating one per request and @@ -64,7 +67,6 @@ pub async fn run( // in memory and co-signs at submission. 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..."); // 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(); @@ -78,15 +80,22 @@ pub async fn run( origin_address: &origin_address, destination_address: &destination_address, slippage_bps: Some(args.slippage), + // Ignored by the streaming endpoint (`stream_query_pairs` drops it). sort_by: Some(sort_by), - max_quotes: Some(args.max_quotes), + max_quotes: Some(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); + let quotes_resp = if args.stream { + stream::fetch_quotes_via_stream(&client, "e_params, output).await? + } else { + let spinner = output.spinner_guard("Fetching cross-chain quotes..."); + let resp = client.get_cross_chain_quotes("e_params).await?; + drop(spinner); + resp + }; if quotes_resp.quotes.is_empty() || !quotes_resp.liquidity_available { return Err(CliError::Api { @@ -446,6 +455,12 @@ pub async fn run( Ok(output.emit_success("cross-chain", &result, metadata, warnings, exit_code)) } +/// Per-mode `--max-quotes` default: 3 buffered, 5 streaming. Range validation +/// (1-10) lives on the clap arg definition. +fn resolve_max_quotes(stream: bool, requested: Option) -> u8 { + requested.unwrap_or(if stream { 5 } else { 3 }) +} + /// A loaded origin wallet — exactly one of EVM, Solana, or Tron, depending on /// the origin chain. Held for the lifetime of a cross-chain swap so we don't /// re-load (and re-prompt the OS keyring) on every step. @@ -609,4 +624,17 @@ mod tron_wiring_tests { let tron = chain::resolve_chain("tron").unwrap(); assert_ne!(solana.chain_type, tron.chain_type); } + + #[test] + fn max_quotes_defaults_per_mode() { + assert_eq!(resolve_max_quotes(false, None), 3); + assert_eq!(resolve_max_quotes(true, None), 5); + } + + #[test] + fn max_quotes_explicit_value_wins_in_both_modes() { + // Range enforcement (1-10) lives on the clap arg, not here. + assert_eq!(resolve_max_quotes(false, Some(7)), 7); + assert_eq!(resolve_max_quotes(true, Some(7)), 7); + } } diff --git a/src/commands/cross_chain/stream.rs b/src/commands/cross_chain/stream.rs new file mode 100644 index 0000000..3e2d81e --- /dev/null +++ b/src/commands/cross_chain/stream.rs @@ -0,0 +1,380 @@ +//! Streaming (`--stream`) quote fetch for `0x cross-chain`. Drives the +//! `/cross-chain/quotes/stream` SSE endpoint via [`SseParser`], surfacing +//! per-quote progress on stderr, and assembles the collected quotes into the +//! same [`CrossChainQuotesResponse`] shape the buffered path returns so the +//! rest of the command flow is untouched. + +use crate::api::cross_chain::{ + CrossChainQuote, CrossChainQuoteParams, CrossChainQuotesResponse, CrossChainStreamEvent, + CrossChainStreamMessage, SseParser, +}; +use crate::api::ApiClient; +use crate::error::{CliError, ErrorCode}; +use crate::output::{OutputHandler, SpinnerGuard}; +use std::time::Duration; + +/// A dropped connection with no terminal event and zero quotes is retried +/// exactly once, after this pause, with the same params (the API issues a +/// fresh zid per request). +const RETRY_DELAY: Duration = Duration::from_millis(1500); + +/// Bytes of the raw body kept for the "no SSE events" error preview. Matches +/// the buffered path's parse-failure preview bound. +const BODY_PREVIEW_MAX: usize = 512; + +/// Outcome of consuming a single stream connection. +enum ConsumeResult { + /// Terminal `result` event seen — the assembled response is final. + Completed(CrossChainQuotesResponse), + /// Non-recoverable: a terminal `error` event, or a 2xx body that carried + /// no SSE events at all. Never retried. + Failed(CliError), + /// The connection ended (clean EOF or read error) before any terminal + /// event. Carries whatever was collected so far and, for a read error, + /// the mapped cause to surface if there's nothing to fall back to. + Dropped { + quotes: Vec, + zid: Option, + allowance_target: Option, + source: Option, + }, +} + +/// Fetch cross-chain quotes over SSE. Prints per-quote progress to stderr +/// (suppressed under `--quiet`) and returns once a terminal event arrives, the +/// connection closes with usable quotes, or the retry budget is spent. +pub(super) async fn fetch_quotes_via_stream( + client: &ApiClient, + params: &CrossChainQuoteParams<'_>, + output: &OutputHandler, +) -> Result { + let spinner = output.spinner_guard("Streaming cross-chain quotes..."); + let mut retried = false; + loop { + // Status errors (auth, rate-limit, 5xx) propagate straight out — the + // middleware already retried transient failures, and a 403 is not a + // dropped connection to retry here. + let response = client.open_cross_chain_quotes_stream(params).await?; + + match consume_stream(response, &spinner).await { + ConsumeResult::Completed(resp) => return Ok(resp), + ConsumeResult::Failed(err) => return Err(err), + ConsumeResult::Dropped { + quotes, + zid, + allowance_target, + source, + } => { + if !quotes.is_empty() { + // Partial success: proceed with what we have rather than + // discarding real quotes to retry — but say so, since the + // set (and therefore the best price) may be incomplete. + spinner.suspend(|| { + output.info(&format!( + "Warning: quote stream ended early; proceeding with {} quote(s), the set may be incomplete.", + quotes.len() + )); + }); + return Ok(assemble(quotes, true, zid, allowance_target)); + } + if !retried { + retried = true; + spinner.suspend(|| { + output.info("Quote stream ended before any quotes; retrying once..."); + }); + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + // Second drop, still nothing. A read error surfaces its cause; + // a clean EOF with no quotes falls through to the caller's + // shared "no liquidity" path via an empty response. + return match source { + Some(err) => Err(err), + None => Ok(assemble(Vec::new(), false, zid, None)), + }; + } + } + } +} + +/// Accumulated per-connection stream state, mutated by [`handle_payload`]. +struct StreamState { + quotes: Vec, + zid: Option, + allowance_target: Option, +} + +/// Read one stream connection to completion, driving the SSE parser and +/// decoding each `data:` payload. +async fn consume_stream(mut response: reqwest::Response, spinner: &SpinnerGuard) -> ConsumeResult { + let status = response.status().as_u16(); + let mut parser = SseParser::new(); + let mut state = StreamState { + quotes: Vec::new(), + zid: None, + allowance_target: None, + }; + let mut payloads_seen: usize = 0; + let mut body_bytes: usize = 0; + let mut body_preview: Vec = Vec::new(); + + loop { + match response.chunk().await { + Ok(Some(bytes)) => { + body_bytes += bytes.len(); + if body_preview.len() < BODY_PREVIEW_MAX { + let take = (BODY_PREVIEW_MAX - body_preview.len()).min(bytes.len()); + body_preview.extend_from_slice(&bytes[..take]); + } + for payload in parser.feed(bytes.as_ref()) { + payloads_seen += 1; + if let Some(done) = handle_payload(&payload, &mut state, spinner) { + return done; + } + } + } + Ok(None) => { + // Flush an event whose terminating blank line never arrived. + if let Some(payload) = parser.finish() { + payloads_seen += 1; + if let Some(done) = handle_payload(&payload, &mut state, spinner) { + return done; + } + } + // A 2xx body with no SSE data events at all (e.g. a proxy + // answering 200 with an HTML page) is a malformed response, + // not an empty quote set — surface it like the buffered + // parse failure instead of misreporting "no liquidity". + if payloads_seen == 0 && body_bytes > 0 { + let preview = String::from_utf8_lossy(&body_preview); + return ConsumeResult::Failed(CliError::Api { + code: ErrorCode::ApiError, + message: "Failed to parse API response: quote stream body contained no SSE events".into(), + status: Some(status), + details: Some(serde_json::json!({ + "body_preview": crate::api::truncate_for_error(&preview) + })), + suggestion: None, + }); + } + return ConsumeResult::Dropped { + quotes: state.quotes, + zid: state.zid, + allowance_target: state.allowance_target, + source: None, + }; + } + Err(e) => { + let source = Some(CliError::Api { + code: if e.is_timeout() { + ErrorCode::NetworkTimeout + } else { + ErrorCode::NetworkError + }, + message: format!("Cross-chain quote stream connection failed: {e}"), + status: None, + details: None, + suggestion: None, + }); + return ConsumeResult::Dropped { + quotes: state.quotes, + zid: state.zid, + allowance_target: state.allowance_target, + source, + }; + } + } + } +} + +/// Decode and apply one SSE payload. Returns `Some` when the event is +/// terminal. Malformed / unmodeled payloads are skipped, not fatal: the +/// endpoint may interleave frames we don't map, and one bad line shouldn't +/// abort a stream mid-discovery. +fn handle_payload( + payload: &str, + state: &mut StreamState, + spinner: &SpinnerGuard, +) -> Option { + let msg: CrossChainStreamMessage = match serde_json::from_str(payload) { + Ok(m) => m, + Err(e) => { + tracing::debug!("skipping undecodable stream payload: {e}"); + return None; + } + }; + if msg.zid.is_some() { + state.zid = msg.zid; + } + match msg.event { + CrossChainStreamEvent::Quote { data } => { + let data = *data; + if data.allowance_target.is_some() { + state.allowance_target = data.allowance_target; + } + let bridge = data.quote.bridge_provider(); + let buy = data.quote.buy_amount.clone(); + state.quotes.push(data.quote); + spinner.set_message(format!( + "Received {} quote(s) — latest: {bridge} → buy {buy}", + state.quotes.len() + )); + None + } + CrossChainStreamEvent::Result { data } => Some(ConsumeResult::Completed(assemble( + std::mem::take(&mut state.quotes), + data.liquidity_available, + state.zid.take(), + state.allowance_target.take(), + ))), + CrossChainStreamEvent::Error { data } => { + Some(ConsumeResult::Failed(map_stream_error(data))) + } + } +} + +/// Assemble collected stream quotes into the buffered response shape so the +/// existing selection / confirm / execute path can consume them unchanged. +fn assemble( + mut quotes: Vec, + liquidity_available: bool, + zid: Option, + allowance_target: Option, +) -> CrossChainQuotesResponse { + sort_quotes_best_price_first(&mut quotes); + CrossChainQuotesResponse { + // A terminal `result` with a missing/false liquidityAvailable must + // not discard quotes that actually arrived. + liquidity_available: liquidity_available || !quotes.is_empty(), + quotes, + zid, + origin_chain: None, + destination_chain: None, + allowance_target, + sell_token: None, + buy_token: None, + } +} + +/// Streamed quotes arrive in discovery order, but the selection layer assumes +/// index 0 is the best price (the buffered endpoint pre-sorts). Re-sort by +/// buy amount descending. If any buyAmount fails to parse as a base-unit +/// integer, leave the arrival order untouched rather than sorting on garbage. +/// The sort is stable, so equal buy amounts keep their discovery order. +fn sort_quotes_best_price_first(quotes: &mut [CrossChainQuote]) { + if quotes.iter().all(|q| q.buy_amount.parse::().is_ok()) { + quotes.sort_by_key(|q| std::cmp::Reverse(q.buy_amount.parse::().unwrap_or(0))); + } +} + +/// Map a terminal `error` event to a `CliError` using the same name→code and +/// suggestion tables as the buffered path's `map_api_error`, so streaming +/// errors classify and advise identically. +fn map_stream_error(data: crate::api::cross_chain::CrossChainStreamErrorData) -> CliError { + let code = data + .code + .as_deref() + .and_then(crate::api::error_code_for_api_name) + .unwrap_or(ErrorCode::ApiError); + let suggestion = + crate::api::suggestion_for_error_code(code, crate::api::EndpointKind::CrossChain); + let message = data + .message + .unwrap_or_else(|| "Cross-chain quote stream returned an error".to_string()); + CliError::Api { + code, + message, + status: None, + details: data.code.map(|c| serde_json::json!({ "code": c })), + suggestion, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::cross_chain::{ + CrossChainStreamErrorData, CrossChainTransaction, CrossChainTxDetails, + }; + + fn quote(buy: &str) -> CrossChainQuote { + CrossChainQuote { + sell_amount: "1000".into(), + buy_amount: buy.into(), + min_buy_amount: "0".into(), + steps: vec![], + transaction: CrossChainTransaction { + chain_type: "evm".into(), + details: CrossChainTxDetails { + to: None, + data: None, + gas: None, + gas_price: None, + value: None, + serialized_transaction: None, + owner_address: None, + }, + }, + gas_costs: None, + issues: None, + estimated_time_seconds: None, + quote_id: None, + } + } + + fn buy_amounts(resp: &CrossChainQuotesResponse) -> Vec<&str> { + resp.quotes.iter().map(|q| q.buy_amount.as_str()).collect() + } + + #[test] + fn assemble_sorts_best_price_first() { + // Streamed quotes arrive in discovery order; index 0 must end up as + // the best (highest) buy amount for the selection layer. + let resp = assemble( + vec![quote("100"), quote("300"), quote("200")], + true, + None, + None, + ); + assert_eq!(buy_amounts(&resp), ["300", "200", "100"]); + } + + #[test] + fn assemble_keeps_arrival_order_when_amounts_unparseable() { + let resp = assemble( + vec![quote("100"), quote("not-a-number"), quote("200")], + true, + None, + None, + ); + assert_eq!(buy_amounts(&resp), ["100", "not-a-number", "200"]); + } + + #[test] + fn assemble_infers_liquidity_from_quotes() { + // A terminal `result` with `{}` data deserializes to + // liquidity_available=false; collected quotes must not be discarded. + let resp = assemble(vec![quote("100")], false, None, None); + assert!(resp.liquidity_available); + assert_eq!(resp.quotes.len(), 1); + // An empty set stays unavailable. + assert!(!assemble(Vec::new(), false, None, None).liquidity_available); + } + + #[test] + fn stream_error_maps_through_shared_tables() { + // Same code and suggestion as the buffered path's NO_LIQUIDITY. + let err = map_stream_error(CrossChainStreamErrorData { + message: Some("no routes".into()), + code: Some("NO_LIQUIDITY".into()), + }); + assert_eq!(err.code(), ErrorCode::NoLiquidity); + assert!(err.suggestion().unwrap().contains("different token pair")); + + let err = map_stream_error(CrossChainStreamErrorData { + message: None, + code: Some("SOMETHING_UNMAPPED".into()), + }); + assert_eq!(err.code(), ErrorCode::ApiError); + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs index 788a032..8ea4bab 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -234,6 +234,16 @@ impl SpinnerGuard { pub fn progress_bar(&self) -> Option<&indicatif::ProgressBar> { self.inner.as_ref() } + + /// Run `f` with the spinner temporarily cleared so anything it writes to + /// stderr doesn't interleave with the active tick line. Calls `f` + /// directly when the spinner is suppressed (--quiet). + pub fn suspend R, R>(&self, f: F) -> R { + match &self.inner { + Some(pb) => pb.suspend(f), + None => f(), + } + } } impl Drop for SpinnerGuard { diff --git a/tests/cli_output.rs b/tests/cli_output.rs index bbe3501..e6e32d7 100644 --- a/tests/cli_output.rs +++ b/tests/cli_output.rs @@ -154,6 +154,107 @@ fn test_unknown_subcommand_exits_2() { cmd().arg("nonexistent").assert().failure().code(2); } +#[test] +fn test_cross_chain_stream_conflicts_with_sort() { + // clap rejects the combination at parse time (exit 2) before any network + // or wallet resolution — the streaming endpoint can't sort quotes. + cmd() + .args([ + "cross-chain", + "--stream", + "--sort", + "speed", + "--from", + "base", + "--to", + "arbitrum", + "--sell", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "--buy", + "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "--amount", + "100", + ]) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("cannot be used with")); +} + +#[test] +fn test_cross_chain_help_lists_stream() { + cmd() + .args(["cross-chain", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--stream")); +} + +#[test] +fn test_cross_chain_stream_flag_parses() { + // --stream with a full arg set (no --sort) must get past clap parsing — + // exit 2 is reserved for usage errors. In an empty HOME it then fails on + // the missing wallet instead. + let (mut cmd, _tmp) = cmd_in_temp_home(); + cmd.env("ZEROEX_API_KEY", "dummy-test-key"); + let output = cmd + .args([ + "cross-chain", + "--stream", + "--from", + "base", + "--to", + "arbitrum", + "--sell", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "--buy", + "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "--amount", + "100", + "--yes", + "-o", + "json", + ]) + .output() + .expect("failed to run"); + + assert!(!output.status.success()); + assert_ne!( + output.status.code(), + Some(2), + "must not be a clap usage error" + ); + + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("invalid JSON error"); + assert_eq!(json["code"], "WALLET_NOT_FOUND"); +} + +#[test] +fn test_cross_chain_max_quotes_out_of_range_rejected() { + // Range enforcement (1-10) is a clap value_parser → usage error, exit 2. + cmd() + .args([ + "cross-chain", + "--from", + "base", + "--to", + "arbitrum", + "--sell", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "--buy", + "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "--amount", + "100", + "--max-quotes", + "11", + ]) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("--max-quotes")); +} + #[test] fn test_swap_unknown_chain() { let output = cmd() @@ -470,7 +571,10 @@ fn test_telemetry_inert_without_compiled_key() { cmd.args(["chains", "-o", "json"]).assert().success(); let queue = tmp.path().join(".0x-config/telemetry-queue.jsonl"); - assert!(!queue.exists(), "telemetry queue should not exist in a keyless build"); + assert!( + !queue.exists(), + "telemetry queue should not exist in a keyless build" + ); // No install id should have been persisted either. let config = tmp.path().join(".0x-config/config.toml"); @@ -488,7 +592,10 @@ fn test_telemetry_inert_without_compiled_key() { fn test_telemetry_opt_out_env_leaves_no_trace() { for (k, v) in [("DO_NOT_TRACK", "1"), ("ZEROEX_TELEMETRY", "0")] { let (mut cmd, tmp) = cmd_in_temp_home(); - cmd.env(k, v).args(["chains", "-o", "json"]).assert().success(); + cmd.env(k, v) + .args(["chains", "-o", "json"]) + .assert() + .success(); let queue = tmp.path().join(".0x-config/telemetry-queue.jsonl"); assert!(!queue.exists(), "{k}={v} should produce no telemetry queue"); } @@ -647,22 +754,38 @@ fn chains_list_includes_tron() { cmd.args(["chains", "-o", "json"]); let out = cmd.output().unwrap(); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.contains("\"tron\""), "chains output should list tron: {stdout}"); - assert!(stdout.contains("\"tvm\""), "chains output should show tvm chain_type: {stdout}"); + assert!( + stdout.contains("\"tron\""), + "chains output should list tron: {stdout}" + ); + assert!( + stdout.contains("\"tvm\""), + "chains output should show tvm chain_type: {stdout}" + ); } #[test] fn swap_rejects_tron_with_cross_chain_hint() { let mut cmd = assert_cmd::Command::cargo_bin("0x").unwrap(); cmd.args([ - "swap", "--chain", "tron", - "--sell", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "--buy", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "--amount", "1000000", "-o", "json-envelope", + "swap", + "--chain", + "tron", + "--sell", + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "--buy", + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "--amount", + "1000000", + "-o", + "json-envelope", ]); let out = cmd.output().unwrap(); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.contains("cross-chain"), "expected cross-chain hint, got: {stdout}"); + assert!( + stdout.contains("cross-chain"), + "expected cross-chain hint, got: {stdout}" + ); } // ─── Agent payments (--pay) ─────────────────────────────────── @@ -691,10 +814,17 @@ fn pay_invalid_value_rejected_by_clap() { // Unknown --pay value is an arg-parse error listing the valid choices. cmd() .args([ - "price", "--chain", "base", - "--sell", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "--buy", "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - "--amount", "100000", "--pay", "bogus", + "price", + "--chain", + "base", + "--sell", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "--buy", + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + "--amount", + "100000", + "--pay", + "bogus", ]) .assert() .failure() @@ -707,10 +837,19 @@ fn pay_rejected_on_solana_chain() { let (mut cmd, _tmp) = cmd_in_temp_home(); let out = cmd .args([ - "price", "--chain", "solana", - "--sell", "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "--buy", "So11111111111111111111111111111111111111112", - "--amount", "1000000", "--pay", "x402-evm", "-o", "json", + "price", + "--chain", + "solana", + "--sell", + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "--buy", + "So11111111111111111111111111111111111111112", + "--amount", + "1000000", + "--pay", + "x402-evm", + "-o", + "json", ]) .output() .expect("failed to run"); @@ -724,10 +863,20 @@ fn pay_rejected_with_gasless() { let (mut cmd, _tmp) = cmd_in_temp_home(); let out = cmd .args([ - "price", "--chain", "base", - "--sell", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "--buy", "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - "--amount", "100000", "--pay", "x402-evm", "--gasless", "-o", "json", + "price", + "--chain", + "base", + "--sell", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "--buy", + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + "--amount", + "100000", + "--pay", + "x402-evm", + "--gasless", + "-o", + "json", ]) .output() .expect("failed to run"); @@ -747,10 +896,21 @@ fn pay_invalid_max_payment_rejected() { "0x0000000000000000000000000000000000000000000000000000000000000001", ) .args([ - "price", "--chain", "base", - "--sell", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "--buy", "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - "--amount", "100000", "--pay", "x402-evm", "--max-payment", "0", "-o", "json", + "price", + "--chain", + "base", + "--sell", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "--buy", + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + "--amount", + "100000", + "--pay", + "x402-evm", + "--max-payment", + "0", + "-o", + "json", ]) .output() .expect("failed to run");