Skip to main content

cow_bridging/
sdk.rs

1//! [`BridgingSdk`] — multi-provider cross-chain bridge aggregator.
2
3use cow_errors::CowError;
4
5use crate::swap_quoter::SwapQuoter;
6
7// ── Bridging constants ──────────────────────────────────────────────────────
8
9/// Bungee bridge backend API path segment.
10pub const BUNGEE_API_PATH: &str = "/api/v1/bungee";
11
12/// Bungee bridge manual API path segment.
13pub const BUNGEE_MANUAL_API_PATH: &str = "/api/v1/bungee-manual";
14
15/// Bungee (Socket) public backend base URL.
16pub const BUNGEE_BASE_URL: &str = "https://public-backend.bungee.exchange";
17
18/// Bungee API URL (base URL + API path).
19pub const BUNGEE_API_URL: &str = "https://public-backend.bungee.exchange/api/v1/bungee";
20
21/// Bungee manual API URL (base URL + manual API path).
22pub const BUNGEE_MANUAL_API_URL: &str =
23    "https://public-backend.bungee.exchange/api/v1/bungee-manual";
24
25/// Bungee events API URL for tracking bridge transactions.
26pub const BUNGEE_EVENTS_API_URL: &str = "https://microservices.socket.tech/loki";
27
28/// Across Protocol bridge API base URL.
29pub const ACROSS_API_URL: &str = "https://app.across.to/api";
30
31/// Default bridge slippage tolerance in basis points (0.5 %).
32pub const DEFAULT_BRIDGE_SLIPPAGE_BPS: u32 = 50;
33
34/// Default gas cost for hook estimation (240 000 gas).
35pub const DEFAULT_GAS_COST_FOR_HOOK_ESTIMATION: u64 = 240_000;
36
37/// Default extra gas for hook estimation (350 000 gas).
38pub const DEFAULT_EXTRA_GAS_FOR_HOOK_ESTIMATION: u64 = 350_000;
39
40/// Default extra gas cost when creating a proxy (400 000 gas).
41pub const DEFAULT_EXTRA_GAS_PROXY_CREATION: u64 = 400_000;
42
43/// URL prefix used to identify bridge hook dapps.
44pub const HOOK_DAPP_BRIDGE_PROVIDER_PREFIX: &str = "cow-sdk://bridging/providers";
45
46/// Bungee bridge hook dapp identifier.
47pub const BUNGEE_HOOK_DAPP_ID: &str = "cow-sdk://bridging/providers/bungee";
48
49/// Across bridge hook dapp identifier.
50pub const ACROSS_HOOK_DAPP_ID: &str = "cow-sdk://bridging/providers/across";
51
52/// Near Intents bridge hook dapp identifier.
53pub const NEAR_INTENTS_HOOK_DAPP_ID: &str = "cow-sdk://bridging/providers/near-intents";
54
55/// Bungee API fallback timeout in milliseconds (5 minutes).
56pub const BUNGEE_API_FALLBACK_TIMEOUT: u64 = 300_000;
57
58use super::{
59    bungee::BungeeProvider,
60    provider::BridgeProvider,
61    types::{BridgeError, QuoteBridgeRequest, QuoteBridgeResponse},
62};
63
64/// High-level cross-chain bridge aggregator.
65///
66/// Holds a list of [`BridgeProvider`] implementations and queries them
67/// concurrently when fetching quotes.
68///
69/// # Example
70///
71/// ```rust,no_run
72/// use cow_bridging::BridgingSdk;
73///
74/// let sdk = BridgingSdk::new().with_bungee("my-api-key");
75/// assert_eq!(sdk.provider_count(), 1);
76/// ```
77#[derive(Default)]
78pub struct BridgingSdk {
79    providers: Vec<Box<dyn BridgeProvider>>,
80}
81
82impl std::fmt::Debug for BridgingSdk {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("BridgingSdk").field("provider_count", &self.providers.len()).finish()
85    }
86}
87
88impl BridgingSdk {
89    /// Create an empty [`BridgingSdk`] with no providers.
90    ///
91    /// # Returns
92    ///
93    /// A new [`BridgingSdk`] instance with an empty provider list.
94    ///
95    /// # Example
96    ///
97    /// ```rust
98    /// use cow_bridging::BridgingSdk;
99    ///
100    /// let sdk = BridgingSdk::new();
101    /// assert_eq!(sdk.provider_count(), 0);
102    /// ```
103    #[must_use]
104    pub fn new() -> Self {
105        Self { providers: vec![] }
106    }
107
108    /// Add the Bungee (Socket) bridge provider using the given API key.
109    ///
110    /// This is a builder-style method that consumes `self` and returns the
111    /// modified instance, allowing chained calls.
112    ///
113    /// # Arguments
114    ///
115    /// * `api_key` — Bungee (Socket) API key used to authenticate requests.
116    ///
117    /// # Returns
118    ///
119    /// The [`BridgingSdk`] instance with the Bungee provider appended.
120    #[must_use]
121    pub fn with_bungee(mut self, api_key: impl Into<String>) -> Self {
122        self.providers.push(Box::new(BungeeProvider::new(api_key)));
123        self
124    }
125
126    /// Register any custom [`BridgeProvider`] implementation.
127    ///
128    /// # Arguments
129    ///
130    /// * `provider` — A type implementing [`BridgeProvider`] that will be boxed and stored
131    ///   alongside any existing providers.
132    pub fn add_provider(&mut self, provider: impl BridgeProvider + 'static) {
133        self.providers.push(Box::new(provider));
134    }
135
136    /// Number of registered providers.
137    ///
138    /// # Returns
139    ///
140    /// The count of [`BridgeProvider`] instances currently registered with
141    /// this SDK.
142    #[must_use]
143    pub fn provider_count(&self) -> usize {
144        self.providers.len()
145    }
146
147    /// Query all registered providers concurrently and return the best quote.
148    ///
149    /// "Best" is defined as the highest [`net_buy_amount`](QuoteBridgeResponse::net_buy_amount).
150    ///
151    /// # Errors
152    ///
153    /// - [`BridgeError::NoProviders`] if no providers support the requested route.
154    /// - [`BridgeError::NoQuote`] if all providers fail or return no quote.
155    pub async fn get_best_quote(
156        &self,
157        req: &QuoteBridgeRequest,
158    ) -> Result<QuoteBridgeResponse, BridgeError> {
159        let eligible: Vec<&dyn BridgeProvider> = self
160            .providers
161            .iter()
162            .filter(|p| p.supports_route(req.sell_chain_id, req.buy_chain_id))
163            .map(|p| p.as_ref())
164            .collect();
165
166        if eligible.is_empty() {
167            return Err(BridgeError::NoProviders);
168        }
169
170        let futures: Vec<_> = eligible.iter().map(|p| p.get_quote(req)).collect();
171        let results = futures::future::join_all(futures).await;
172
173        let best = results
174            .into_iter()
175            .filter_map(|r| r.ok())
176            .max_by_key(QuoteBridgeResponse::net_buy_amount);
177
178        best.ok_or(BridgeError::NoQuote)
179    }
180
181    /// Query all registered providers concurrently and return all results.
182    ///
183    /// Providers that do not support the route are skipped.
184    /// Both successful quotes and errors are included in the output.
185    ///
186    /// # Errors
187    ///
188    /// Individual provider failures are returned as [`CowError`] entries
189    /// in the result vector rather than short-circuiting the entire call.
190    pub async fn get_all_quotes(
191        &self,
192        req: &QuoteBridgeRequest,
193    ) -> Vec<Result<QuoteBridgeResponse, CowError>> {
194        let eligible: Vec<&dyn BridgeProvider> = self
195            .providers
196            .iter()
197            .filter(|p| p.supports_route(req.sell_chain_id, req.buy_chain_id))
198            .map(|p| p.as_ref())
199            .collect();
200
201        let futures: Vec<_> = eligible.iter().map(|p| p.get_quote(req)).collect();
202        futures::future::join_all(futures).await
203    }
204}
205
206// ── Type guard result types ──────────────────────────────────────────────────
207
208use super::types::BridgeQuoteResults;
209
210/// A bridge quote paired with a callback-style post function.
211///
212/// In the `TypeScript` SDK this includes a closure `postSwapOrderFromQuote`.
213/// In Rust, the struct holds the data needed to construct the order; the
214/// caller orchestrates posting via the order-book API.
215#[derive(Debug, Clone)]
216pub struct BridgeQuoteAndPost {
217    /// Swap quote results (amounts, costs, app-data).
218    pub swap: QuoteBridgeResponse,
219    /// Bridge quote results.
220    pub bridge: BridgeQuoteResults,
221}
222
223/// A simple quote-and-post result for same-chain swaps.
224///
225/// In the `TypeScript` SDK this is `QuoteAndPost` from the trading package.
226/// Here it wraps the quote response; order posting is handled separately.
227#[derive(Debug, Clone)]
228pub struct QuoteAndPost {
229    /// The quote response.
230    pub quote: QuoteBridgeResponse,
231}
232
233/// Union of same-chain and cross-chain quote results.
234///
235/// Mirrors the `TypeScript` `CrossChainQuoteAndPost = QuoteAndPost | BridgeQuoteAndPost`.
236#[derive(Debug, Clone)]
237pub enum CrossChainQuoteAndPost {
238    /// Same-chain swap (no bridging needed).
239    SameChain(Box<QuoteAndPost>),
240    /// Cross-chain swap with bridging.
241    CrossChain(Box<BridgeQuoteAndPost>),
242}
243
244// ── Type guard functions ─────────────────────────────────────────────────────
245
246/// Returns `true` if the result is a [`BridgeQuoteAndPost`] (cross-chain with
247/// both swap and bridge data).
248///
249/// Mirrors the `TypeScript` `isBridgeQuoteAndPost` type guard.
250#[must_use]
251pub const fn is_bridge_quote_and_post(result: &CrossChainQuoteAndPost) -> bool {
252    matches!(result, CrossChainQuoteAndPost::CrossChain(_))
253}
254
255/// Returns `true` if the result is a [`QuoteAndPost`] (same-chain swap).
256///
257/// Mirrors the `TypeScript` `isQuoteAndPost` type guard.
258#[must_use]
259pub const fn is_quote_and_post(result: &CrossChainQuoteAndPost) -> bool {
260    matches!(result, CrossChainQuoteAndPost::SameChain(_))
261}
262
263/// Assert that the result is a [`BridgeQuoteAndPost`], returning a reference
264/// to it or an error.
265///
266/// # Errors
267///
268/// Returns [`BridgeError::QuoteError`] if the result is not a cross-chain quote.
269pub fn assert_is_bridge_quote_and_post(
270    result: &CrossChainQuoteAndPost,
271) -> Result<&BridgeQuoteAndPost, BridgeError> {
272    match result {
273        CrossChainQuoteAndPost::CrossChain(bqp) => Ok(bqp.as_ref()),
274        CrossChainQuoteAndPost::SameChain(_) => {
275            Err(BridgeError::QuoteError("expected BridgeQuoteAndPost, got QuoteAndPost".to_owned()))
276        }
277    }
278}
279
280/// Assert that the result is a [`QuoteAndPost`], returning a reference to it
281/// or an error.
282///
283/// # Errors
284///
285/// Returns [`BridgeError::QuoteError`] if the result is not a same-chain quote.
286pub fn assert_is_quote_and_post(
287    result: &CrossChainQuoteAndPost,
288) -> Result<&QuoteAndPost, BridgeError> {
289    match result {
290        CrossChainQuoteAndPost::SameChain(qp) => Ok(qp.as_ref()),
291        CrossChainQuoteAndPost::CrossChain(_) => {
292            Err(BridgeError::QuoteError("expected QuoteAndPost, got BridgeQuoteAndPost".to_owned()))
293        }
294    }
295}
296
297// ── Cross-chain order flow ───────────────────────────────────────────────────
298
299use crate::{
300    across::{EvmLogEntry, get_deposit_params},
301    types::{BridgeHook, BridgeQuoteResult, BridgeStatus, BridgeStatusResult, CrossChainOrder},
302};
303use alloy_primitives::Address;
304
305/// Parameters for [`get_cross_chain_order`].
306#[derive(Debug)]
307pub struct GetCrossChainOrderParams<'a> {
308    /// Chain ID where the order was settled.
309    pub chain_id: u64,
310    /// The `CoW` Protocol order UID.
311    pub order_id: String,
312    /// Full app-data JSON of the order.
313    pub full_app_data: Option<String>,
314    /// Transaction hash of the settlement.
315    pub trade_tx_hash: String,
316    /// Raw log entries from the settlement transaction.
317    pub logs: &'a [EvmLogEntry],
318    /// Optional settlement contract address override.
319    pub settlement_override: Option<Address>,
320}
321
322/// Build a [`CrossChainOrder`] from settlement transaction data.
323///
324/// Parses Across deposit events and `CoW` Trade events from the logs, matches
325/// them by index, and constructs the bridging deposit parameters.
326///
327/// This is a simplified version of the `TypeScript` `getCrossChainOrder` that
328/// does not call the `OrderBookApi` (the caller must provide the order data
329/// and logs). For full orchestration, use the `OrderBookApi` directly.
330///
331/// # Errors
332///
333/// Returns [`BridgeError::QuoteError`] if the deposit parameters cannot be
334/// extracted from the logs.
335pub fn get_cross_chain_order(
336    params: &GetCrossChainOrderParams<'_>,
337) -> Result<CrossChainOrder, BridgeError> {
338    let bridging_params = get_deposit_params(
339        params.chain_id,
340        &params.order_id,
341        params.logs,
342        params.settlement_override,
343    )
344    .ok_or_else(|| {
345        BridgeError::QuoteError(format!(
346            "bridging params cannot be derived from transaction: {}",
347            params.trade_tx_hash
348        ))
349    })?;
350
351    Ok(CrossChainOrder {
352        chain_id: params.chain_id,
353        status_result: BridgeStatusResult::new(BridgeStatus::Unknown),
354        bridging_params,
355        trade_tx_hash: params.trade_tx_hash.clone(),
356        explorer_url: None,
357    })
358}
359
360/// Context passed to [`get_bridge_signed_hook`].
361///
362/// Mirrors the `HookBridgeResultContext` struct of the `TypeScript`
363/// SDK: the fields carry the pieces of state a [`crate::provider::HookBridgeProvider`]
364/// needs to both request the hook and derive its nonce.
365#[derive(Debug)]
366pub struct GetBridgeSignedHookContext<'a> {
367    /// Signer that will EIP-712-sign the hook bundle through `cow-shed`.
368    pub signer: &'a alloy_signer_local::PrivateKeySigner,
369    /// Gas-limit estimated for the bridge post-hook. Passed verbatim to
370    /// [`crate::provider::HookBridgeProvider::get_signed_hook`].
371    pub hook_gas_limit: u64,
372    /// Source chain of the bridge — picks the right cow-shed factory /
373    /// domain separator.
374    pub chain_id: cow_chains::SupportedChainId,
375    /// Hook validity deadline (UNIX seconds). Usually equals
376    /// `order_to_sign.valid_to` from the enclosing swap quote.
377    pub deadline: u64,
378}
379
380/// Output of [`get_bridge_signed_hook`].
381///
382/// Bundles the signed hook together with the raw bridge call and the
383/// provider's original [`QuoteBridgeResponse`] so the caller can wire
384/// the three into a final order.
385#[derive(Debug, Clone)]
386pub struct GetBridgeSignedHookOutput {
387    /// Signed bridge hook ready to be attached as a post-interaction
388    /// on the enclosing `CoW` order's app-data.
389    pub hook: BridgeHook,
390    /// Raw EVM call that the cow-shed proxy will execute.
391    pub unsigned_bridge_call: cow_chains::EvmCall,
392    /// The bridging quote produced upstream of the signing step.
393    pub bridging_quote: QuoteBridgeResponse,
394}
395
396/// Produce a signed bridge hook for a [`crate::provider::HookBridgeProvider`].
397///
398/// Mirrors `getBridgeSignedHook` from
399/// `packages/bridging/src/BridgingSdk/getBridgeSignedHook.ts`. The
400/// function:
401///
402/// 1. Asks the provider for a bridge quote ([`crate::provider::BridgeProvider::get_quote`]).
403/// 2. Asks the provider for the unsigned EVM call that will initiate the bridge
404///    ([`crate::provider::HookBridgeProvider::get_unsigned_bridge_call`]).
405/// 3. Derives a deterministic hook nonce from the call data and the order's `valid_to` deadline —
406///    `keccak256(abi.encodePacked(data, uint256 deadline))`. This ties the signed hook to a
407///    specific combination of bridge call and order deadline.
408/// 4. Delegates to [`crate::provider::HookBridgeProvider::get_signed_hook`] to produce the EIP-712
409///    signed hook via `cow-shed`.
410///
411/// # Errors
412///
413/// Returns [`BridgeError::TxBuildError`] if any of the provider calls
414/// fail, wrapping the underlying [`CowError`].
415pub async fn get_bridge_signed_hook<P: crate::provider::HookBridgeProvider + ?Sized>(
416    hook_provider: &P,
417    bridge_request: &QuoteBridgeRequest,
418    context: GetBridgeSignedHookContext<'_>,
419) -> Result<GetBridgeSignedHookOutput, BridgeError> {
420    // 1. Bridge quote from the provider.
421    let bridging_quote = hook_provider
422        .get_quote(bridge_request)
423        .await
424        .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
425
426    // 2. Raw EVM call.
427    let unsigned_bridge_call = hook_provider
428        .get_unsigned_bridge_call(bridge_request, &bridging_quote)
429        .await
430        .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
431
432    // 3. Derive the hook nonce.
433    let nonce_hex = derive_hook_nonce(&unsigned_bridge_call.data, context.deadline);
434
435    // 4. Sign the hook.
436    let hook = hook_provider
437        .get_signed_hook(
438            context.chain_id,
439            &unsigned_bridge_call,
440            &nonce_hex,
441            context.deadline,
442            context.hook_gas_limit,
443            context.signer,
444        )
445        .await
446        .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
447
448    Ok(GetBridgeSignedHookOutput { hook, unsigned_bridge_call, bridging_quote })
449}
450
451/// Derive the bridge-hook nonce as the `TypeScript` SDK does:
452///
453/// ```text
454/// nonce = keccak256( abi.encodePacked(bytes calldata, uint256 deadline) )
455/// ```
456///
457/// `abi.encodePacked` on `(bytes, uint256)` concatenates the raw bytes
458/// of `data` with the 32-byte big-endian `deadline` — no 32-byte offset
459/// prefix like the non-packed encoding would introduce.
460///
461/// The returned string is the `0x`-prefixed lowercase hex of the hash,
462/// matching the TS `solidityKeccak256` output.
463fn derive_hook_nonce(data: &[u8], deadline: u64) -> String {
464    let deadline_be: [u8; 32] = alloy_primitives::U256::from(deadline).to_be_bytes();
465    let mut packed = Vec::with_capacity(data.len() + 32);
466    packed.extend_from_slice(data);
467    packed.extend_from_slice(&deadline_be);
468    let hash = alloy_primitives::keccak256(&packed);
469    format!("{hash:#x}")
470}
471
472/// Parameters for [`get_quote_with_bridge`].
473#[derive(Clone)]
474pub struct GetQuoteWithBridgeParams {
475    /// The swap-and-bridge request.
476    pub swap_and_bridge_request: QuoteBridgeRequest,
477    /// Slippage tolerance in basis points for the swap leg.
478    pub slippage_bps: u32,
479    /// Optional caller-supplied app-data metadata to merge into the
480    /// auto-generated `hooks` / `bridging` metadata.
481    ///
482    /// Corresponds to `advanced_settings.app_data.metadata` in the
483    /// `TypeScript` SDK — the load-bearing bit of the cow-sdk#852 fix.
484    pub advanced_settings_metadata: Option<serde_json::Value>,
485    /// Optional quote-time signer. When provided on the hook branch,
486    /// [`get_quote_with_hook_bridge`] produces a **real** EIP-712 signed
487    /// hook via [`get_bridge_signed_hook`] instead of the placeholder
488    /// mock used for cost estimation. The receiver-account branch
489    /// ignores this field.
490    ///
491    /// Corresponds to the `quoteSigner` parameter of the TS SDK's
492    /// `getQuoteWithHookBridge`. Keep it `None` when the final signing
493    /// wallet is not available yet (e.g. hardware wallet flows).
494    pub quote_signer: Option<std::sync::Arc<alloy_signer_local::PrivateKeySigner>>,
495    /// Hook deadline (UNIX seconds). Defaults to `u32::MAX` when `None`.
496    ///
497    /// Threaded into [`get_bridge_signed_hook`] so the hook nonce binds
498    /// to the same validity as the enclosing order.
499    pub hook_deadline: Option<u64>,
500}
501
502impl std::fmt::Debug for GetQuoteWithBridgeParams {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        f.debug_struct("GetQuoteWithBridgeParams")
505            .field("swap_and_bridge_request", &self.swap_and_bridge_request)
506            .field("slippage_bps", &self.slippage_bps)
507            .field("advanced_settings_metadata", &self.advanced_settings_metadata)
508            .field("quote_signer", &self.quote_signer.is_some())
509            .field("hook_deadline", &self.hook_deadline)
510            .finish()
511    }
512}
513
514/// Get a quote that includes bridging (cross-chain).
515///
516/// Dispatches to the hook-bridge or receiver-account-bridge branch based on
517/// the provider's runtime type, mirroring the `TypeScript`
518/// `getQuoteWithBridge` in
519/// `packages/bridging/src/BridgingSdk/getQuoteWithBridge.ts`.
520///
521/// # Flow
522///
523/// 1. Reject non-sell orders (cross-chain only supports `OrderKind::Sell`).
524/// 2. If the provider implements [`crate::provider::HookBridgeProvider`], delegate to
525///    [`get_quote_with_hook_bridge`].
526/// 3. Otherwise, if the provider implements [`crate::provider::ReceiverAccountBridgeProvider`],
527///    delegate to [`get_quote_with_receiver_account_bridge`].
528/// 4. Fall through to an error if the provider implements neither.
529///
530/// # Errors
531///
532/// * [`BridgeError::OnlySellOrderSupported`] when `kind != Sell`.
533/// * [`BridgeError::TxBuildError`] when the provider implements neither sub-trait.
534/// * Any error returned by the delegated branch.
535pub async fn get_quote_with_bridge(
536    params: &GetQuoteWithBridgeParams,
537    provider: &dyn BridgeProvider,
538    quoter: &dyn SwapQuoter,
539) -> Result<BridgeQuoteAndPost, BridgeError> {
540    if params.swap_and_bridge_request.kind != cow_types::OrderKind::Sell {
541        return Err(BridgeError::OnlySellOrderSupported);
542    }
543
544    if let Some(hook_provider) = provider.as_hook_bridge_provider() {
545        return get_quote_with_hook_bridge(hook_provider, params, quoter).await;
546    }
547
548    if let Some(receiver_provider) = provider.as_receiver_account_bridge_provider() {
549        return get_quote_with_receiver_account_bridge(receiver_provider, params, quoter).await;
550    }
551
552    Err(BridgeError::TxBuildError(format!(
553        "provider {name} implements neither HookBridgeProvider nor ReceiverAccountBridgeProvider",
554        name = provider.info().name,
555    )))
556}
557
558/// Get a quote without bridging (same-chain swap).
559///
560/// Delegates to the [`SwapQuoter`] — equivalent to calling `TradingSdk::get_quote_only`
561/// with the bridge request fields mapped to `TradeParameters`.
562///
563/// Mirrors `getQuoteWithoutBridge` in the `TypeScript` SDK.
564///
565/// # Errors
566///
567/// Returns [`BridgeError::TxBuildError`] when the quoter fails.
568pub async fn get_quote_without_bridge(
569    request: &QuoteBridgeRequest,
570    quoter: &dyn SwapQuoter,
571) -> Result<QuoteAndPost, BridgeError> {
572    let buy_token_evm = request.buy_token.to_evm().ok_or_else(|| {
573        BridgeError::TxBuildError(
574            "same-chain swaps require an EVM buy_token; got TokenAddress::Raw".into(),
575        )
576    })?;
577    let params = crate::swap_quoter::SwapQuoteParams {
578        owner: request.account,
579        chain_id: request.sell_chain_id,
580        sell_token: request.sell_token,
581        sell_token_decimals: request.sell_token_decimals,
582        buy_token: buy_token_evm,
583        buy_token_decimals: request.buy_token_decimals,
584        amount: request.sell_amount,
585        kind: request.kind,
586        slippage_bps: request.slippage_bps,
587        app_data_json: None,
588    };
589    let outcome =
590        quoter.quote_swap(params).await.map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
591
592    Ok(QuoteAndPost {
593        quote: QuoteBridgeResponse {
594            provider: "same-chain".to_owned(),
595            sell_amount: outcome.sell_amount,
596            buy_amount: outcome.buy_amount_after_slippage,
597            fee_amount: outcome.fee_amount,
598            estimated_secs: 0,
599            bridge_hook: None,
600        },
601    })
602}
603
604/// Get a swap quote for an intermediate hop, as a stand-alone helper.
605///
606/// Builds swap parameters from the bridge request (using `buy_token` as
607/// the intermediate token destination) and asks the [`SwapQuoter`] to
608/// price it. Mirrors `getSwapQuote` in the `TypeScript` SDK.
609///
610/// # Errors
611///
612/// Returns [`BridgeError::TxBuildError`] when the quoter fails.
613pub async fn get_swap_quote(
614    request: &QuoteBridgeRequest,
615    quoter: &dyn SwapQuoter,
616) -> Result<QuoteBridgeResponse, BridgeError> {
617    let buy_token_evm = request.buy_token.to_evm().ok_or_else(|| {
618        BridgeError::TxBuildError("intermediate swap quote requires an EVM buy_token".into())
619    })?;
620    let params = crate::swap_quoter::SwapQuoteParams {
621        owner: request.account,
622        chain_id: request.sell_chain_id,
623        sell_token: request.sell_token,
624        sell_token_decimals: request.sell_token_decimals,
625        buy_token: buy_token_evm,
626        buy_token_decimals: request.buy_token_decimals,
627        amount: request.sell_amount,
628        kind: request.kind,
629        slippage_bps: request.slippage_bps,
630        app_data_json: None,
631    };
632    let outcome =
633        quoter.quote_swap(params).await.map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
634
635    Ok(QuoteBridgeResponse {
636        provider: "swap".to_owned(),
637        sell_amount: outcome.sell_amount,
638        buy_amount: outcome.buy_amount_after_slippage,
639        fee_amount: outcome.fee_amount,
640        estimated_secs: 0,
641        bridge_hook: None,
642    })
643}
644
645/// Quote the intermediate swap step of a cross-chain bridge flow.
646///
647/// Given a bridge request and a [`BridgeProvider`], this:
648/// 1. Asks the provider for candidate intermediate tokens
649///    ([`BridgeProvider::get_intermediate_tokens`]).
650/// 2. Picks the best candidate via [`crate::utils::determine_intermediate_token`].
651/// 3. Asks the [`SwapQuoter`] to price the swap from the sell token to the intermediate token.
652/// 4. Merges any caller-supplied `app_data.metadata` with the auto-generated `hooks` / `bridging`
653///    metadata — the cow-sdk#852 fix: caller-provided partner / UTM metadata must survive the
654///    intermediate quote instead of being overwritten.
655/// 5. Returns a [`QuoteBridgeResponse`] whose `buy_amount` is the swap's `afterSlippage.buyAmount`
656///    — the amount handed off to the bridge.
657///
658/// # Arguments
659///
660/// * `request` — the top-level bridge quote request.
661/// * `provider` — the [`BridgeProvider`] that will route the bridge step.
662/// * `quoter` — a [`SwapQuoter`] that can price the intermediate swap (typically a wrapper around
663///   `cow_trading::TradingSdk::get_quote_only`).
664/// * `advanced_settings_metadata` — optional caller-supplied app-data metadata JSON. When `Some`,
665///   its keys are merged with the auto-generated `hooks` / `bridging` entries (see cow-sdk#852).
666///
667/// # Errors
668///
669/// * [`BridgeError::NoIntermediateTokens`] if the provider returns an empty candidate list.
670/// * [`BridgeError::TxBuildError`] if the swap quote fails, wrapping the underlying [`CowError`].
671pub async fn get_intermediate_swap_result(
672    request: &QuoteBridgeRequest,
673    provider: &dyn crate::provider::BridgeProvider,
674    quoter: &dyn SwapQuoter,
675    advanced_settings_metadata: Option<&serde_json::Value>,
676) -> Result<QuoteBridgeResponse, BridgeError> {
677    use crate::utils::determine_intermediate_token;
678
679    // 1. Ask the provider for candidates.
680    let candidates = provider
681        .get_intermediate_tokens(request)
682        .await
683        .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
684
685    if candidates.is_empty() {
686        return Err(BridgeError::NoIntermediateTokens);
687    }
688
689    // 2. Pick the best candidate. Intermediate hops always live on the
690    // source chain (EVM), so filter out non-EVM candidates defensively.
691    let candidate_addrs: Vec<alloy_primitives::Address> =
692        candidates.iter().filter_map(|t| t.address.to_evm()).collect();
693    let intermediate = determine_intermediate_token(
694        request.sell_chain_id,
695        request.sell_token,
696        &candidate_addrs,
697        &foldhash::HashSet::default(),
698        false,
699    )?;
700    let intermediate_info = candidates
701        .iter()
702        .find(|t| t.address == intermediate)
703        .cloned()
704        .ok_or_else(intermediate_not_in_candidates_err)?;
705    let intermediate_evm =
706        intermediate_info.address.to_evm().ok_or_else(intermediate_must_be_evm_err)?;
707
708    // 3. Build the app-data JSON with caller metadata preserved (#852 fix).
709    let app_data_json = build_intermediate_app_data_json(advanced_settings_metadata, provider);
710
711    // 4. Quote the swap.
712    let params = crate::swap_quoter::SwapQuoteParams {
713        owner: request.account,
714        chain_id: request.sell_chain_id,
715        sell_token: request.sell_token,
716        sell_token_decimals: request.sell_token_decimals,
717        buy_token: intermediate_evm,
718        buy_token_decimals: intermediate_info.decimals,
719        amount: request.sell_amount,
720        kind: request.kind,
721        slippage_bps: request.slippage_bps,
722        app_data_json: Some(app_data_json),
723    };
724    let outcome =
725        quoter.quote_swap(params).await.map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
726
727    // 5. Wrap the outcome in a QuoteBridgeResponse.
728    Ok(QuoteBridgeResponse {
729        provider: provider.info().name.clone(),
730        sell_amount: outcome.sell_amount,
731        buy_amount: outcome.buy_amount_after_slippage,
732        fee_amount: outcome.fee_amount,
733        estimated_secs: 0,
734        bridge_hook: None,
735    })
736}
737
738/// Build the `appData` JSON for the intermediate swap, preserving
739/// caller-supplied metadata.
740///
741/// Implements the cow-sdk#852 fix: when `advanced_settings.app_data.metadata`
742/// exists, its keys are spread into the final metadata object *before*
743/// the auto-generated `hooks` and `bridging` entries. This matches the
744/// `TypeScript` flow:
745///
746/// ```text
747/// appData.metadata = {
748///     ...advanced_settings?.app_data?.metadata,
749///     hooks,
750///     bridging: { providerId: provider.info.dappId },
751/// }
752/// ```
753///
754/// The return value is a stringified JSON document ready to be passed
755/// through a [`SwapQuoter`].
756// Defensive error builders for `get_intermediate_swap_result`. Both arms
757// are guarded upstream (`filter_map(to_evm)` produces only EVM addresses
758// that are present in `candidates`) so they're unreachable in practice;
759// exercised directly in the tests below to keep codecov happy.
760fn intermediate_not_in_candidates_err() -> BridgeError {
761    BridgeError::TxBuildError("intermediate token not in candidates".into())
762}
763
764fn intermediate_must_be_evm_err() -> BridgeError {
765    BridgeError::TxBuildError("intermediate token must be EVM on the source chain".into())
766}
767
768fn build_intermediate_app_data_json(
769    caller_metadata: Option<&serde_json::Value>,
770    provider: &dyn crate::provider::BridgeProvider,
771) -> String {
772    let mut metadata = caller_metadata.and_then(|v| v.as_object().cloned()).unwrap_or_default();
773
774    // Overwrite with auto-generated fields — they are the load-bearing
775    // bits for the on-chain bridge flow.
776    metadata.insert(
777        "bridging".to_owned(),
778        serde_json::json!({ "providerId": provider.info().dapp_id }),
779    );
780    // Hooks are populated by the orchestration layer in PR #7 once the
781    // real post-hook is known; for now carry an empty hooks entry so
782    // the shape mirrors the TS output.
783    if !metadata.contains_key("hooks") {
784        metadata.insert("hooks".to_owned(), serde_json::json!({ "post": [] }));
785    }
786
787    let doc = serde_json::json!({
788        "version": "1.4.0",
789        "appCode": "CoW Bridging",
790        "metadata": metadata,
791    });
792    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_owned())
793}
794
795// ── Timeout ─────────────────────────────────────────────────────────────────
796
797/// Create a bridge request timeout future.
798///
799/// Returns a future that resolves to a [`BridgeError::Timeout`] after
800/// `timeout_ms` milliseconds. This is the Rust equivalent of the `TypeScript`
801/// `createBridgeRequestTimeoutPromise(timeoutMs, prefix)`.
802///
803/// # Example
804///
805/// ```rust,no_run
806/// use cow_bridging::sdk::create_bridge_request_timeout;
807///
808/// # async fn example() {
809/// let timeout = create_bridge_request_timeout(20_000, "Across");
810/// // Use with tokio::select! or futures::select! to race against a real request
811/// # }
812/// ```
813#[cfg(feature = "native")]
814pub async fn create_bridge_request_timeout(timeout_ms: u64, prefix: &str) -> BridgeError {
815    tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)).await;
816    BridgeError::ApiError(format!("{prefix} timeout after {timeout_ms}ms"))
817}
818
819// ── Strategy factory ────────────────────────────────────────────────────────
820
821/// Strategy variant for quote retrieval.
822///
823/// Mirrors the `TypeScript` `createStrategies` factory which returns
824/// `SingleQuoteStrategy`, `MultiQuoteStrategy`, and `BestQuoteStrategy`.
825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
826pub enum QuoteStrategy {
827    /// Query a single provider (or fall back to a direct swap for same-chain).
828    Single,
829    /// Query all providers and return all results.
830    Multi,
831    /// Query all providers and return the best quote.
832    Best,
833}
834
835impl QuoteStrategy {
836    /// Return the strategy name.
837    ///
838    /// # Returns
839    ///
840    /// A static string label for this strategy variant:
841    /// `"SingleQuoteStrategy"`, `"MultiQuoteStrategy"`, or `"BestQuoteStrategy"`.
842    ///
843    /// # Example
844    ///
845    /// ```rust
846    /// use cow_bridging::sdk::QuoteStrategy;
847    ///
848    /// assert_eq!(QuoteStrategy::Best.name(), "BestQuoteStrategy");
849    /// ```
850    #[must_use]
851    pub const fn name(self) -> &'static str {
852        match self {
853            Self::Single => "SingleQuoteStrategy",
854            Self::Multi => "MultiQuoteStrategy",
855            Self::Best => "BestQuoteStrategy",
856        }
857    }
858}
859
860/// Create all available quote strategies.
861///
862/// Returns the three strategy variants (Single, Multi, Best). In the
863/// `TypeScript` SDK each is a class instance backed by an optional
864/// `intermediateTokensCache`; in Rust the strategies are simple enum
865/// variants and caching is handled by the caller.
866///
867/// Mirrors `createStrategies(cache)` from `strategies/createStrategies.ts`.
868#[must_use]
869pub const fn create_strategies() -> [QuoteStrategy; 3] {
870    [QuoteStrategy::Single, QuoteStrategy::Multi, QuoteStrategy::Best]
871}
872
873// ── Provider quote execution ────────────────────────────────────────────────
874
875use super::types::MultiQuoteResult;
876
877/// Default total timeout for multi-provider quotes (40 seconds).
878pub const DEFAULT_TOTAL_TIMEOUT_MS: u64 = 40_000;
879
880/// Default per-provider timeout (20 seconds).
881pub const DEFAULT_PROVIDER_TIMEOUT_MS: u64 = 20_000;
882
883/// Execute quotes across providers concurrently with a global timeout.
884///
885/// Spawns one future per provider, races them against a global timeout, and
886/// returns whatever results completed. Providers that did not respond in time
887/// get a timeout error in the results vector.
888///
889/// Mirrors `executeProviderQuotes` from `BridgingSdk/utils.ts`.
890///
891/// # Errors
892///
893/// Does not return an error itself — individual provider errors are captured
894/// in the returned [`MultiQuoteResult`] entries.
895#[cfg(feature = "native")]
896pub async fn execute_provider_quotes(
897    sdk: &BridgingSdk,
898    request: &QuoteBridgeRequest,
899    timeout_ms: u64,
900) -> Vec<MultiQuoteResult> {
901    use futures::future::join_all;
902
903    let futs: Vec<_> = sdk
904        .providers
905        .iter()
906        .map(|p| {
907            let name = p.name().to_owned();
908            async move {
909                let result = p.get_quote(request).await;
910                match result {
911                    Ok(quote) => MultiQuoteResult {
912                        provider_dapp_id: name,
913                        quote: Some(crate::types::BridgeQuoteAmountsAndCosts {
914                            before_fee: crate::types::BridgeAmounts {
915                                sell_amount: quote.sell_amount,
916                                buy_amount: quote.buy_amount,
917                            },
918                            after_fee: crate::types::BridgeAmounts {
919                                sell_amount: quote.sell_amount,
920                                buy_amount: quote.buy_amount.saturating_sub(quote.fee_amount),
921                            },
922                            after_slippage: crate::types::BridgeAmounts {
923                                sell_amount: quote.sell_amount,
924                                buy_amount: quote.buy_amount.saturating_sub(quote.fee_amount),
925                            },
926                            costs: crate::types::BridgeCosts {
927                                bridging_fee: crate::types::BridgingFee {
928                                    fee_bps: 0,
929                                    amount_in_sell_currency: quote.fee_amount,
930                                    amount_in_buy_currency: quote.fee_amount,
931                                },
932                            },
933                            slippage_bps: request.slippage_bps,
934                        }),
935                        error: None,
936                    },
937                    Err(e) => MultiQuoteResult {
938                        provider_dapp_id: name,
939                        quote: None,
940                        error: Some(e.to_string()),
941                    },
942                }
943            }
944        })
945        .collect();
946
947    // Race all futures against a global timeout
948    let fetched_results =
949        tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), join_all(futs)).await;
950
951    match fetched_results {
952        Ok(results) => results,
953        Err(_timeout) => {
954            // Return timeout errors for all providers
955            sdk.providers
956                .iter()
957                .map(|p| MultiQuoteResult {
958                    provider_dapp_id: p.name().to_owned(),
959                    quote: None,
960                    error: Some(format!("Multi-quote timeout after {timeout_ms}ms")),
961                })
962                .collect()
963        }
964    }
965}
966
967/// Fetch a multi-quote from providers with timeout.
968///
969/// Executes quotes across the SDK's providers concurrently and returns all
970/// results (including errors). Results are sorted by buy amount descending.
971///
972/// Mirrors `fetchMultiQuote` from `strategies/utils.ts` and the
973/// `MultiQuoteStrategy.execute` method.
974///
975/// # Errors
976///
977/// Individual provider errors are captured in the results vector.
978#[cfg(feature = "native")]
979pub async fn fetch_multi_quote(
980    sdk: &BridgingSdk,
981    request: &QuoteBridgeRequest,
982    timeout_ms: Option<u64>,
983) -> Vec<MultiQuoteResult> {
984    let timeout = timeout_ms.map_or(DEFAULT_TOTAL_TIMEOUT_MS, |v| v);
985    let mut results = execute_provider_quotes(sdk, request, timeout).await;
986
987    // Fill timeout results
988    let dapp_ids: Vec<String> = sdk.providers.iter().map(|p| p.name().to_owned()).collect();
989    crate::utils::fill_timeout_results(&mut results, &dapp_ids);
990
991    // Sort by buy amount after slippage (best first)
992    results.sort_by(|a, b| {
993        let a_amount =
994            a.quote.as_ref().map_or(alloy_primitives::U256::ZERO, |q| q.after_slippage.buy_amount);
995        let b_amount =
996            b.quote.as_ref().map_or(alloy_primitives::U256::ZERO, |q| q.after_slippage.buy_amount);
997        b_amount.cmp(&a_amount)
998    });
999
1000    results
1001}
1002
1003// ── Cache key ───────────────────────────────────────────────────────────────
1004
1005/// Compute a cache key for a bridge request.
1006///
1007/// Produces a deterministic string key from the request's chain IDs and
1008/// token addresses, suitable for use as a hash-map key.
1009///
1010/// Mirrors `getCacheKey` from `BridgingSdk/utils.ts` (which delegates to
1011/// `hashQuote`).
1012///
1013/// # Returns
1014///
1015/// A string in the format `"{sell_chain}-{buy_chain}-{sell_token}-{buy_token}"`
1016/// where token addresses are hex-encoded with a `0x` prefix.
1017#[must_use]
1018pub fn get_cache_key(request: &QuoteBridgeRequest) -> String {
1019    let buy_token = match &request.buy_token {
1020        crate::types::TokenAddress::Evm(addr) => format!("{addr:#x}"),
1021        crate::types::TokenAddress::Raw(s) => format!("raw:{s}"),
1022    };
1023    format!(
1024        "{}-{}-{:#x}-{}",
1025        request.sell_chain_id, request.buy_chain_id, request.sell_token, buy_token,
1026    )
1027}
1028
1029// ── Safe callback invocation ────────────────────────────────────────────────
1030
1031/// Safely invoke a "best quote" callback, catching panics.
1032///
1033/// Mirrors `safeCallBestQuoteCallback` from `BridgingSdk/utils.ts`.
1034/// If the callback panics, the panic is caught and logged via
1035/// [`tracing::warn!`]; it does not propagate.
1036///
1037/// # Arguments
1038///
1039/// * `callback` — An optional closure to invoke with the best quote result. If `None`, this
1040///   function is a no-op.
1041/// * `result` — The [`MultiQuoteResult`] to pass to the callback.
1042pub fn safe_call_best_quote_callback<F: FnOnce(&MultiQuoteResult)>(
1043    callback: Option<F>,
1044    result: &MultiQuoteResult,
1045) {
1046    if let Some(cb) = callback {
1047        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1048            cb(result);
1049        }));
1050        if let Err(e) = outcome {
1051            tracing::warn!("Error in best-quote callback: {:?}", e);
1052        }
1053    }
1054}
1055
1056/// Safely invoke a "progressive quote" callback, catching panics.
1057///
1058/// Mirrors `safeCallProgressiveCallback` from `BridgingSdk/utils.ts`.
1059/// If the callback panics, the panic is caught and logged via
1060/// [`tracing::warn!`]; it does not propagate.
1061///
1062/// # Arguments
1063///
1064/// * `callback` — An optional closure to invoke with the progressive quote result. If `None`, this
1065///   function is a no-op.
1066/// * `result` — The [`MultiQuoteResult`] to pass to the callback.
1067pub fn safe_call_progressive_callback<F: FnOnce(&MultiQuoteResult)>(
1068    callback: Option<F>,
1069    result: &MultiQuoteResult,
1070) {
1071    if let Some(cb) = callback {
1072        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1073            cb(result);
1074        }));
1075        if let Err(e) = outcome {
1076            tracing::warn!("Error in progressive-quote callback: {:?}", e);
1077        }
1078    }
1079}
1080
1081// ── Hook-based and receiver-account bridge quote ────────────────────────────
1082
1083/// Orchestrate a cross-chain quote for a hook-based bridge (Across, Bungee, …).
1084///
1085/// Mirrors `getQuoteWithHookBridge` from
1086/// `packages/bridging/src/BridgingSdk/getQuoteWithBridge.ts:209-312`.
1087///
1088/// # Flow
1089///
1090/// 1. Estimate gas for the bridge post-hook
1091///    ([`crate::provider::HookBridgeProvider::get_gas_limit_estimation_for_hook`]).
1092/// 2. Quote the intermediate swap via [`get_intermediate_swap_result`] (app-data carries a
1093///    cost-estimation mock hook so the swap quote sees realistic gas).
1094/// 3. Either:
1095///    - **With a signer** (`params.quote_signer.is_some()`): call [`get_bridge_signed_hook`] to
1096///      fetch the bridge quote, unsigned call, derive the hook nonce, and produce a real EIP-712
1097///      signed hook via `cow-shed`.
1098///    - **Without a signer**: fetch [`BridgeProvider::get_quote`] +
1099///      [`crate::provider::HookBridgeProvider::get_unsigned_bridge_call`] and package with a
1100///      placeholder mock hook — the real hook is signed later during the post flow.
1101/// 4. Package the result in a [`BridgeQuoteAndPost`] whose `bridge.bridge_call_details` carries the
1102///    unsigned call and either the real or mock pre-authorized hook.
1103///
1104/// # Errors
1105///
1106/// Returns [`BridgeError::TxBuildError`] if any downstream quoter / provider call fails.
1107pub async fn get_quote_with_hook_bridge(
1108    hook_provider: &dyn crate::provider::HookBridgeProvider,
1109    params: &GetQuoteWithBridgeParams,
1110    quoter: &dyn SwapQuoter,
1111) -> Result<BridgeQuoteAndPost, BridgeError> {
1112    // 1. Gas limit estimation for the post-hook (real value used by the mock hook so that the
1113    //    intermediate swap sees realistic gas).
1114    let hook_gas_limit = hook_provider
1115        .get_gas_limit_estimation_for_hook(
1116            true,
1117            Some(DEFAULT_EXTRA_GAS_FOR_HOOK_ESTIMATION),
1118            Some(DEFAULT_EXTRA_GAS_PROXY_CREATION),
1119        )
1120        .await
1121        .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
1122
1123    // 2. Intermediate swap result (reuses PR #6 implementation).
1124    let swap = get_intermediate_swap_result(
1125        &params.swap_and_bridge_request,
1126        hook_provider,
1127        quoter,
1128        params.advanced_settings_metadata.as_ref(),
1129    )
1130    .await?;
1131
1132    // 3. Produce the bridge-call details — signed or mock depending on whether a `quote_signer` is
1133    //    available.
1134    let (unsigned_bridge_call, bridge_response, pre_authorized_bridging_hook) =
1135        if let Some(signer) = &params.quote_signer {
1136            let chain_id = cow_chains::SupportedChainId::try_from(
1137                params.swap_and_bridge_request.sell_chain_id,
1138            )
1139            .map_err(|e| {
1140                BridgeError::TxBuildError(format!(
1141                    "unsupported sell_chain_id {} for hook signing: {e}",
1142                    params.swap_and_bridge_request.sell_chain_id,
1143                ))
1144            })?;
1145            let deadline = params.hook_deadline.unwrap_or_else(|| u64::from(u32::MAX));
1146            let ctx = GetBridgeSignedHookContext {
1147                signer: signer.as_ref(),
1148                hook_gas_limit,
1149                chain_id,
1150                deadline,
1151            };
1152            let out =
1153                get_bridge_signed_hook(hook_provider, &params.swap_and_bridge_request, ctx).await?;
1154            (out.unsigned_bridge_call, out.bridging_quote, out.hook)
1155        } else {
1156            let bridge_response = hook_provider
1157                .get_quote(&params.swap_and_bridge_request)
1158                .await
1159                .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
1160            let unsigned_call = hook_provider
1161                .get_unsigned_bridge_call(&params.swap_and_bridge_request, &bridge_response)
1162                .await
1163                .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
1164            let mock_post_hook = crate::utils::hook_mock_for_cost_estimation(hook_gas_limit);
1165            let hook = BridgeHook {
1166                post_hook: mock_post_hook,
1167                recipient: format!("{:#x}", params.swap_and_bridge_request.account),
1168            };
1169            (unsigned_call, bridge_response, hook)
1170        };
1171
1172    // 4. Assemble the BridgeQuoteResult + BridgeCallDetails.
1173    let quote = minimal_bridge_quote_result(&params.swap_and_bridge_request, &bridge_response);
1174
1175    Ok(BridgeQuoteAndPost {
1176        swap,
1177        bridge: crate::types::BridgeQuoteResults {
1178            provider_info: hook_provider.info().clone(),
1179            quote,
1180            bridge_call_details: Some(crate::types::BridgeCallDetails {
1181                unsigned_bridge_call,
1182                pre_authorized_bridging_hook,
1183            }),
1184            bridge_receiver_override: None,
1185        },
1186    })
1187}
1188
1189/// Orchestrate a cross-chain quote for a receiver-account-based bridge (NEAR Intents, …).
1190///
1191/// Mirrors `getQuoteWithReceiverAccountBridge` from
1192/// `packages/bridging/src/BridgingSdk/getQuoteWithBridge.ts:145-207`.
1193///
1194/// # Flow
1195///
1196/// 1. Quote the intermediate swap via [`get_intermediate_swap_result`] (no hook injection — the
1197///    bridge is triggered by the deposit itself, not a post-hook).
1198/// 2. Ask the provider for a bridge quote ([`BridgeProvider::get_quote`]).
1199/// 3. Ask the provider for the deposit-address override
1200///    ([`crate::provider::ReceiverAccountBridgeProvider::get_bridge_receiver_override`]).
1201/// 4. Package the result in a [`BridgeQuoteAndPost`] where `bridge.bridge_receiver_override` holds
1202///    the deposit address and `bridge.bridge_call_details` is `None`.
1203///
1204/// # Errors
1205///
1206/// Returns [`BridgeError::TxBuildError`] if any downstream quoter / provider call fails.
1207pub async fn get_quote_with_receiver_account_bridge(
1208    receiver_provider: &dyn crate::provider::ReceiverAccountBridgeProvider,
1209    params: &GetQuoteWithBridgeParams,
1210    quoter: &dyn SwapQuoter,
1211) -> Result<BridgeQuoteAndPost, BridgeError> {
1212    // 1. Intermediate swap result — no hook; just the metadata fix (#852).
1213    let swap = get_intermediate_swap_result(
1214        &params.swap_and_bridge_request,
1215        receiver_provider,
1216        quoter,
1217        params.advanced_settings_metadata.as_ref(),
1218    )
1219    .await?;
1220
1221    // 2. Bridge quote.
1222    let bridge_response = receiver_provider
1223        .get_quote(&params.swap_and_bridge_request)
1224        .await
1225        .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
1226
1227    // 3. Deposit-address override.
1228    let receiver_override = receiver_provider
1229        .get_bridge_receiver_override(&params.swap_and_bridge_request, &bridge_response)
1230        .await
1231        .map_err(|e| BridgeError::TxBuildError(e.to_string()))?;
1232
1233    let quote = minimal_bridge_quote_result(&params.swap_and_bridge_request, &bridge_response);
1234
1235    Ok(BridgeQuoteAndPost {
1236        swap,
1237        bridge: crate::types::BridgeQuoteResults {
1238            provider_info: receiver_provider.info().clone(),
1239            quote,
1240            bridge_call_details: None,
1241            bridge_receiver_override: Some(receiver_override),
1242        },
1243    })
1244}
1245
1246/// Build a minimal [`crate::types::BridgeQuoteResult`] from a provider's
1247/// [`QuoteBridgeResponse`].
1248///
1249/// The orchestration layer doesn't have access to the richer
1250/// provider-specific conversion (`to_bridge_quote_result` for Across,
1251/// `bungee_to_bridge_quote_result` for Bungee) because those take
1252/// provider-specific API responses as input. We rebuild the minimal
1253/// `BridgeQuoteResult` from the `QuoteBridgeResponse` alone so the
1254/// orchestrator can wrap the result in a [`BridgeQuoteAndPost`].
1255///
1256/// The simplification is intentional: `BridgeQuoteResult` holds more
1257/// granular fee breakdown than `QuoteBridgeResponse` does, so a subset
1258/// of fields end up defaulted.
1259fn minimal_bridge_quote_result(
1260    request: &QuoteBridgeRequest,
1261    response: &QuoteBridgeResponse,
1262) -> crate::types::BridgeQuoteResult {
1263    use crate::types::{
1264        BridgeAmounts, BridgeCosts, BridgeFees, BridgeLimits, BridgeQuoteAmountsAndCosts,
1265        BridgingFee,
1266    };
1267
1268    let fee = response.fee_amount;
1269    let before_fee_buy = response.buy_amount.saturating_add(fee);
1270
1271    BridgeQuoteResult {
1272        id: None,
1273        signature: None,
1274        attestation_signature: None,
1275        quote_body: None,
1276        is_sell: request.kind == cow_types::OrderKind::Sell,
1277        amounts_and_costs: BridgeQuoteAmountsAndCosts {
1278            before_fee: BridgeAmounts {
1279                sell_amount: response.sell_amount,
1280                buy_amount: before_fee_buy,
1281            },
1282            after_fee: BridgeAmounts {
1283                sell_amount: response.sell_amount,
1284                buy_amount: response.buy_amount,
1285            },
1286            after_slippage: BridgeAmounts {
1287                sell_amount: response.sell_amount,
1288                buy_amount: response.buy_amount,
1289            },
1290            costs: BridgeCosts {
1291                bridging_fee: BridgingFee {
1292                    fee_bps: 0,
1293                    amount_in_sell_currency: fee,
1294                    amount_in_buy_currency: fee,
1295                },
1296            },
1297            slippage_bps: request.slippage_bps,
1298        },
1299        expected_fill_time_seconds: Some(response.estimated_secs),
1300        quote_timestamp: 0,
1301        fees: BridgeFees { bridge_fee: fee, destination_gas_fee: alloy_primitives::U256::ZERO },
1302        limits: BridgeLimits {
1303            min_deposit: alloy_primitives::U256::ZERO,
1304            max_deposit: alloy_primitives::U256::MAX,
1305        },
1306    }
1307}
1308
1309// ── Test utilities ──────────────────────────────────────────────────────────
1310
1311#[cfg(test)]
1312pub mod test_helpers {
1313    //! Test helper utilities ported from the `TypeScript` bridging test package.
1314    //!
1315    //! Mirrors:
1316    //! - `expectToEqual` from `test/utils.ts`
1317    //! - `getMockSigner` / `getPk` / `getWallet` / `getRpcProvider` from `test/getWallet.ts`
1318
1319    use alloy_primitives::Address;
1320    use alloy_signer_local::PrivateKeySigner;
1321
1322    /// A well-known test private key (DO NOT use in production).
1323    ///
1324    /// This is the standard Hardhat account #0 key.
1325    pub const TEST_PRIVATE_KEY: &str =
1326        "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1327
1328    /// Return the test private key hex string.
1329    ///
1330    /// Mirrors `getPk()` from the `TypeScript` test utilities.
1331    #[must_use]
1332    pub fn get_pk() -> &'static str {
1333        TEST_PRIVATE_KEY
1334    }
1335
1336    /// Create a [`PrivateKeySigner`] from the test private key.
1337    ///
1338    /// Mirrors `getMockSigner()` / `getWallet()` from the `TypeScript` test utilities.
1339    #[must_use]
1340    pub fn get_mock_signer() -> PrivateKeySigner {
1341        TEST_PRIVATE_KEY.parse::<PrivateKeySigner>().expect("valid test key")
1342    }
1343
1344    /// Alias for [`get_mock_signer`].
1345    #[must_use]
1346    pub fn get_wallet() -> PrivateKeySigner {
1347        get_mock_signer()
1348    }
1349
1350    /// Return a test RPC URL.
1351    ///
1352    /// Mirrors `getRpcProvider()` from the `TypeScript` test utilities.
1353    /// Returns the default Ethereum mainnet public RPC endpoint.
1354    #[must_use]
1355    pub fn get_rpc_provider() -> &'static str {
1356        "https://eth.llamarpc.com"
1357    }
1358
1359    /// Assert that two serializable values produce the same JSON string.
1360    ///
1361    /// Mirrors `expectToEqual(a, b)` from the `TypeScript` test utilities,
1362    /// which compares `JSON.stringify(a, jsonWithBigintReplacer)` outputs.
1363    ///
1364    /// # Panics
1365    ///
1366    /// Panics if the serialised forms differ.
1367    pub fn expect_to_equal<T: serde::Serialize>(actual: &T, expected: &T) {
1368        let actual_json = serde_json::to_string_pretty(actual).expect("failed to serialise actual");
1369        let expected_json =
1370            serde_json::to_string_pretty(expected).expect("failed to serialise expected");
1371        assert_eq!(actual_json, expected_json);
1372    }
1373
1374    /// Return the address corresponding to the test private key.
1375    #[must_use]
1376    pub fn test_address() -> Address {
1377        get_mock_signer().address()
1378    }
1379
1380    #[cfg(test)]
1381    mod tests {
1382        use super::*;
1383
1384        #[test]
1385        fn mock_signer_has_expected_address() {
1386            let signer = get_mock_signer();
1387            // Hardhat account #0
1388            let expected: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".parse().unwrap();
1389            assert_eq!(signer.address(), expected);
1390        }
1391
1392        #[test]
1393        fn expect_to_equal_passes_for_equal_values() {
1394            expect_to_equal(&42u64, &42u64);
1395        }
1396
1397        #[test]
1398        #[should_panic]
1399        fn expect_to_equal_panics_for_different_values() {
1400            expect_to_equal(&42u64, &43u64);
1401        }
1402
1403        #[test]
1404        fn get_pk_returns_key() {
1405            assert_eq!(get_pk().len(), 64);
1406        }
1407
1408        #[test]
1409        fn get_rpc_provider_returns_url() {
1410            assert!(get_rpc_provider().starts_with("https://"));
1411        }
1412    }
1413}
1414
1415#[cfg(test)]
1416#[allow(clippy::tests_outside_test_module, reason = "inner module pattern")]
1417mod intermediate_swap_tests {
1418    use alloy_primitives::{B256, U256};
1419    use cow_types::OrderKind;
1420
1421    use super::*;
1422    use crate::{
1423        provider::{
1424            BridgeNetworkInfo, BridgeStatusFuture, BridgingParamsFuture, BuyTokensFuture,
1425            IntermediateTokensFuture, NetworksFuture, QuoteFuture,
1426        },
1427        swap_quoter::{QuoteSwapFuture, SwapQuoteOutcome, SwapQuoteParams},
1428        types::{
1429            BridgeProviderInfo, BridgeProviderType, BuyTokensParams, GetProviderBuyTokens,
1430            IntermediateTokenInfo,
1431        },
1432    };
1433
1434    fn dummy_info(name: &str) -> BridgeProviderInfo {
1435        BridgeProviderInfo {
1436            name: name.to_owned(),
1437            logo_url: String::new(),
1438            dapp_id: format!("cow-sdk://bridging/providers/{name}"),
1439            website: String::new(),
1440            provider_type: BridgeProviderType::HookBridgeProvider,
1441        }
1442    }
1443
1444    struct FixedProvider {
1445        info: BridgeProviderInfo,
1446        tokens: Vec<IntermediateTokenInfo>,
1447    }
1448
1449    impl BridgeProvider for FixedProvider {
1450        fn info(&self) -> &BridgeProviderInfo {
1451            &self.info
1452        }
1453        fn supports_route(&self, _s: u64, _b: u64) -> bool {
1454            true
1455        }
1456        fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
1457            Box::pin(async { Ok(Vec::<BridgeNetworkInfo>::new()) })
1458        }
1459        fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
1460            let info = self.info.clone();
1461            Box::pin(
1462                async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
1463            )
1464        }
1465        fn get_intermediate_tokens<'a>(
1466            &'a self,
1467            _req: &'a QuoteBridgeRequest,
1468        ) -> IntermediateTokensFuture<'a> {
1469            let tokens = self.tokens.clone();
1470            Box::pin(async move { Ok(tokens) })
1471        }
1472        fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
1473            Box::pin(async {
1474                Ok(QuoteBridgeResponse {
1475                    provider: "fixed".into(),
1476                    sell_amount: U256::ZERO,
1477                    buy_amount: U256::ZERO,
1478                    fee_amount: U256::ZERO,
1479                    estimated_secs: 0,
1480                    bridge_hook: None,
1481                })
1482            })
1483        }
1484        fn get_bridging_params<'a>(
1485            &'a self,
1486            _c: u64,
1487            _o: &'a cow_orderbook::types::Order,
1488            _t: B256,
1489            _s: Option<Address>,
1490        ) -> BridgingParamsFuture<'a> {
1491            Box::pin(async { Ok(None) })
1492        }
1493        fn get_explorer_url(&self, _id: &str) -> String {
1494            String::new()
1495        }
1496        fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
1497            Box::pin(async {
1498                Ok(BridgeStatusResult {
1499                    status: BridgeStatus::Unknown,
1500                    fill_time_in_seconds: None,
1501                    deposit_tx_hash: None,
1502                    fill_tx_hash: None,
1503                })
1504            })
1505        }
1506    }
1507
1508    struct CapturingQuoter {
1509        captured: std::sync::OnceLock<SwapQuoteParams>,
1510        outcome: SwapQuoteOutcome,
1511    }
1512
1513    impl SwapQuoter for CapturingQuoter {
1514        fn quote_swap<'a>(&'a self, params: SwapQuoteParams) -> QuoteSwapFuture<'a> {
1515            self.captured.set(params).ok();
1516            let outcome = self.outcome.clone();
1517            Box::pin(async move { Ok(outcome) })
1518        }
1519    }
1520
1521    fn usdc_token() -> IntermediateTokenInfo {
1522        IntermediateTokenInfo {
1523            chain_id: 1,
1524            address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
1525                .parse::<Address>()
1526                .unwrap()
1527                .into(),
1528            decimals: 6,
1529            symbol: "USDC".into(),
1530            name: "USD Coin".into(),
1531            logo_url: None,
1532        }
1533    }
1534
1535    fn sample_request() -> QuoteBridgeRequest {
1536        QuoteBridgeRequest {
1537            sell_chain_id: 1,
1538            buy_chain_id: 42_161,
1539            sell_token: Address::repeat_byte(0x11),
1540            sell_token_decimals: 18,
1541            buy_token: Address::repeat_byte(0x22).into(),
1542            buy_token_decimals: 6,
1543            sell_amount: U256::from(1_000_000u64),
1544            account: Address::repeat_byte(0x33),
1545            owner: None,
1546            receiver: None,
1547            bridge_recipient: None,
1548            slippage_bps: 50,
1549            bridge_slippage_bps: None,
1550            kind: OrderKind::Sell,
1551        }
1552    }
1553
1554    fn default_outcome() -> SwapQuoteOutcome {
1555        SwapQuoteOutcome {
1556            sell_amount: U256::from(1_000_000u64),
1557            buy_amount_after_slippage: U256::from(999_500u64),
1558            fee_amount: U256::from(500u64),
1559            valid_to: 9_999_999,
1560            app_data_hex: "0xabc".into(),
1561            full_app_data: "{\"version\":\"1.4.0\"}".into(),
1562        }
1563    }
1564
1565    #[tokio::test]
1566    async fn errors_when_provider_has_no_candidates() {
1567        let provider = FixedProvider { info: dummy_info("p"), tokens: vec![] };
1568        let quoter =
1569            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1570        let err = get_intermediate_swap_result(&sample_request(), &provider, &quoter, None)
1571            .await
1572            .unwrap_err();
1573        assert!(matches!(err, BridgeError::NoIntermediateTokens));
1574    }
1575
1576    #[tokio::test]
1577    async fn picks_first_candidate_and_returns_wrapped_outcome() {
1578        let provider = FixedProvider { info: dummy_info("p"), tokens: vec![usdc_token()] };
1579        let quoter =
1580            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1581        let resp = get_intermediate_swap_result(&sample_request(), &provider, &quoter, None)
1582            .await
1583            .unwrap();
1584        assert_eq!(resp.provider, "p");
1585        assert_eq!(resp.buy_amount, U256::from(999_500u64));
1586        assert_eq!(resp.fee_amount, U256::from(500u64));
1587    }
1588
1589    #[tokio::test]
1590    async fn threads_intermediate_token_to_quoter() {
1591        let provider = FixedProvider { info: dummy_info("p"), tokens: vec![usdc_token()] };
1592        let quoter =
1593            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1594        get_intermediate_swap_result(&sample_request(), &provider, &quoter, None).await.unwrap();
1595        let captured = quoter.captured.get().cloned().expect("quoter called");
1596        assert_eq!(captured.buy_token, usdc_token().address);
1597        assert_eq!(captured.buy_token_decimals, 6);
1598        assert_eq!(captured.chain_id, 1);
1599    }
1600
1601    // ── cow-sdk#852 metadata preservation ────────────────────────────────
1602
1603    #[tokio::test]
1604    async fn fix_852_preserves_caller_metadata() {
1605        let provider = FixedProvider { info: dummy_info("cow-prov"), tokens: vec![usdc_token()] };
1606        let quoter =
1607            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1608        let caller_meta = serde_json::json!({
1609            "partnerFee":   { "bps": 25, "recipient": "0xpartner" },
1610            "utm":          { "utmSource": "cow-widget" },
1611            "orderClass":   { "orderClass": "market" }
1612        });
1613
1614        get_intermediate_swap_result(&sample_request(), &provider, &quoter, Some(&caller_meta))
1615            .await
1616            .unwrap();
1617
1618        let captured = quoter.captured.get().cloned().expect("quoter called");
1619        let app_data_json = captured.app_data_json.expect("app_data threaded through");
1620        let parsed: serde_json::Value = serde_json::from_str(&app_data_json).unwrap();
1621        let metadata = parsed.get("metadata").expect("metadata key present");
1622
1623        // Caller metadata survived.
1624        assert_eq!(
1625            metadata.get("partnerFee").and_then(|v| v.get("bps")).and_then(|v| v.as_u64()),
1626            Some(25)
1627        );
1628        assert_eq!(
1629            metadata.get("utm").and_then(|v| v.get("utmSource")).and_then(|v| v.as_str()),
1630            Some("cow-widget")
1631        );
1632        assert_eq!(
1633            metadata.get("orderClass").and_then(|v| v.get("orderClass")).and_then(|v| v.as_str()),
1634            Some("market")
1635        );
1636
1637        // Auto-generated bridging entry present.
1638        assert_eq!(
1639            metadata.get("bridging").and_then(|v| v.get("providerId")).and_then(|v| v.as_str()),
1640            Some("cow-sdk://bridging/providers/cow-prov")
1641        );
1642
1643        // Hooks default to an empty post list if the caller didn't supply any.
1644        assert!(metadata.get("hooks").is_some());
1645    }
1646
1647    #[tokio::test]
1648    async fn bridging_entry_overwrites_caller_attempt_to_inject_its_own() {
1649        let provider = FixedProvider { info: dummy_info("cow-prov"), tokens: vec![usdc_token()] };
1650        let quoter =
1651            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1652        // Caller tries to inject a different providerId — the auto-generated
1653        // one must win because that's what the on-chain hook encodes.
1654        let caller_meta = serde_json::json!({
1655            "bridging": { "providerId": "caller-spoofed" },
1656        });
1657
1658        get_intermediate_swap_result(&sample_request(), &provider, &quoter, Some(&caller_meta))
1659            .await
1660            .unwrap();
1661        let captured = quoter.captured.get().cloned().unwrap();
1662        let parsed: serde_json::Value =
1663            serde_json::from_str(&captured.app_data_json.unwrap()).unwrap();
1664        assert_eq!(
1665            parsed.pointer("/metadata/bridging/providerId").and_then(|v| v.as_str()),
1666            Some("cow-sdk://bridging/providers/cow-prov")
1667        );
1668    }
1669
1670    // ── Error-path coverage ─────────────────────────────────────────────
1671
1672    #[tokio::test]
1673    async fn propagates_quoter_error_as_tx_build_error() {
1674        struct FailingQuoter;
1675        impl SwapQuoter for FailingQuoter {
1676            fn quote_swap<'a>(&'a self, _p: SwapQuoteParams) -> QuoteSwapFuture<'a> {
1677                Box::pin(async {
1678                    Err(cow_errors::CowError::Api { status: 500, body: "orderbook down".into() })
1679                })
1680            }
1681        }
1682        let provider = FixedProvider { info: dummy_info("p"), tokens: vec![usdc_token()] };
1683        let err = get_intermediate_swap_result(&sample_request(), &provider, &FailingQuoter, None)
1684            .await
1685            .unwrap_err();
1686        assert!(
1687            matches!(&err, BridgeError::TxBuildError(s) if s.contains("500") && s.contains("orderbook down")),
1688            "got {err:?}"
1689        );
1690    }
1691
1692    #[tokio::test]
1693    async fn errors_when_all_candidates_are_the_sell_token() {
1694        // `determine_intermediate_token` filters candidates equal to the
1695        // sell token when `allow_intermediate_eq_sell = false`. With ≥ 2
1696        // candidates all equal to the sell token the filter empties and
1697        // the function must surface `NoIntermediateTokens`.
1698        let req = sample_request();
1699        let same = |chain| IntermediateTokenInfo {
1700            chain_id: chain,
1701            address: req.sell_token.into(),
1702            decimals: 18,
1703            symbol: "SELL".into(),
1704            name: "Sell Token".into(),
1705            logo_url: None,
1706        };
1707        struct Never;
1708        // `Never` is a deliberate trap: the test asserts the quoter is
1709        // never called, so the body must remain uncovered.
1710        #[cfg_attr(coverage_nightly, coverage(off))]
1711        impl SwapQuoter for Never {
1712            fn quote_swap<'a>(&'a self, _p: SwapQuoteParams) -> QuoteSwapFuture<'a> {
1713                Box::pin(async { panic!("quoter should not be called") })
1714            }
1715        }
1716        let provider = FixedProvider {
1717            info: dummy_info("p"),
1718            tokens: vec![same(req.sell_chain_id), same(req.sell_chain_id)],
1719        };
1720        let err = get_intermediate_swap_result(&req, &provider, &Never, None).await.unwrap_err();
1721        assert!(matches!(err, BridgeError::NoIntermediateTokens));
1722    }
1723
1724    #[tokio::test]
1725    async fn provider_info_name_is_threaded_into_response() {
1726        let provider = FixedProvider { info: dummy_info("zany"), tokens: vec![usdc_token()] };
1727        let quoter =
1728            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1729        let resp = get_intermediate_swap_result(&sample_request(), &provider, &quoter, None)
1730            .await
1731            .unwrap();
1732        assert_eq!(resp.provider, "zany");
1733    }
1734
1735    #[tokio::test]
1736    async fn non_object_caller_metadata_is_ignored_gracefully() {
1737        let provider = FixedProvider { info: dummy_info("p"), tokens: vec![usdc_token()] };
1738        let quoter =
1739            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1740        // A JSON scalar is not a metadata object — the function must
1741        // tolerate it without panicking and still inject the bridging entry.
1742        let bogus = serde_json::json!("not-an-object");
1743        get_intermediate_swap_result(&sample_request(), &provider, &quoter, Some(&bogus))
1744            .await
1745            .unwrap();
1746        let captured = quoter.captured.get().cloned().unwrap();
1747        let parsed: serde_json::Value =
1748            serde_json::from_str(&captured.app_data_json.unwrap()).unwrap();
1749        assert!(parsed.pointer("/metadata/bridging/providerId").is_some());
1750    }
1751
1752    #[tokio::test]
1753    async fn caller_hooks_entry_is_preserved_when_present() {
1754        // If the caller already supplied a hooks entry, we must not clobber
1755        // it with the empty default — some flows pre-populate `hooks.pre`.
1756        let provider = FixedProvider { info: dummy_info("p"), tokens: vec![usdc_token()] };
1757        let quoter =
1758            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1759        let caller_meta = serde_json::json!({
1760            "hooks": { "pre": [{ "target": "0xabc", "callData": "0x", "gasLimit": "100000" }], "post": [] },
1761        });
1762        get_intermediate_swap_result(&sample_request(), &provider, &quoter, Some(&caller_meta))
1763            .await
1764            .unwrap();
1765        let captured = quoter.captured.get().cloned().unwrap();
1766        let parsed: serde_json::Value =
1767            serde_json::from_str(&captured.app_data_json.unwrap()).unwrap();
1768        let pre = parsed
1769            .pointer("/metadata/hooks/pre")
1770            .and_then(|v| v.as_array())
1771            .expect("pre array present");
1772        assert_eq!(pre.len(), 1);
1773    }
1774
1775    #[tokio::test]
1776    async fn fixed_provider_surface_is_callable_for_coverage() {
1777        // Exercise every `FixedProvider` trait method once — otherwise the
1778        // trait-impl rows stay uncovered because `get_intermediate_swap_result`
1779        // only calls `info()` + `get_intermediate_tokens()`.
1780        use alloy_primitives::{Address, B256};
1781        let p = FixedProvider { info: dummy_info("surface"), tokens: vec![usdc_token()] };
1782        assert!(p.supports_route(1, 10));
1783        assert!(p.get_networks().await.unwrap().is_empty());
1784        let toks = p
1785            .get_buy_tokens(BuyTokensParams {
1786                sell_chain_id: 1,
1787                buy_chain_id: 10,
1788                sell_token_address: None,
1789            })
1790            .await
1791            .unwrap();
1792        assert!(toks.tokens.is_empty());
1793        assert_eq!(p.get_quote(&sample_request()).await.unwrap().provider, "fixed");
1794        let order = cow_orderbook::api::mock_get_order(&format!("0x{}", "aa".repeat(56)));
1795        assert!(p.get_bridging_params(1, &order, B256::ZERO, None).await.unwrap().is_none());
1796        assert!(p.get_explorer_url("x").is_empty());
1797        assert_eq!(p.get_status("x", 1).await.unwrap().status, BridgeStatus::Unknown);
1798        // `Address` is imported to silence the unused-import lint if the
1799        // local scope ever loses its reference.
1800        let _ = Address::ZERO;
1801    }
1802
1803    #[tokio::test]
1804    async fn no_caller_metadata_still_produces_bridging_entry() {
1805        let provider = FixedProvider { info: dummy_info("cow-prov"), tokens: vec![usdc_token()] };
1806        let quoter =
1807            CapturingQuoter { captured: std::sync::OnceLock::new(), outcome: default_outcome() };
1808        get_intermediate_swap_result(&sample_request(), &provider, &quoter, None).await.unwrap();
1809        let captured = quoter.captured.get().cloned().unwrap();
1810        let parsed: serde_json::Value =
1811            serde_json::from_str(&captured.app_data_json.unwrap()).unwrap();
1812        assert!(parsed.pointer("/metadata/bridging/providerId").is_some());
1813        assert!(parsed.pointer("/metadata/hooks").is_some());
1814    }
1815}
1816
1817// ── Orchestration tests (PR #7) ──────────────────────────────────────────────
1818
1819#[cfg(test)]
1820#[allow(clippy::tests_outside_test_module, reason = "inner module pattern")]
1821mod orchestration_tests {
1822    use alloy_primitives::{B256, U256};
1823    use cow_chains::EvmCall;
1824    use cow_types::OrderKind;
1825
1826    use super::*;
1827    use crate::{
1828        provider::{
1829            BridgeNetworkInfo, BridgeStatusFuture, BridgingParamsFuture, BuyTokensFuture,
1830            GasEstimationFuture, HookBridgeProvider, IntermediateTokensFuture, NetworksFuture,
1831            QuoteFuture, ReceiverAccountBridgeProvider, ReceiverOverrideFuture, SignedHookFuture,
1832            UnsignedCallFuture,
1833        },
1834        swap_quoter::{QuoteSwapFuture, SwapQuoteOutcome, SwapQuoteParams},
1835        types::{
1836            BridgeProviderInfo, BridgeProviderType, BuyTokensParams, GetProviderBuyTokens,
1837            IntermediateTokenInfo,
1838        },
1839    };
1840
1841    fn hook_info() -> BridgeProviderInfo {
1842        BridgeProviderInfo {
1843            name: "mock-hook".into(),
1844            logo_url: String::new(),
1845            dapp_id: "cow-sdk://bridging/providers/mock-hook".into(),
1846            website: String::new(),
1847            provider_type: BridgeProviderType::HookBridgeProvider,
1848        }
1849    }
1850
1851    fn receiver_info() -> BridgeProviderInfo {
1852        BridgeProviderInfo {
1853            name: "mock-receiver".into(),
1854            logo_url: String::new(),
1855            dapp_id: "cow-sdk://bridging/providers/mock-receiver".into(),
1856            website: String::new(),
1857            provider_type: BridgeProviderType::ReceiverAccountBridgeProvider,
1858        }
1859    }
1860
1861    fn usdc() -> IntermediateTokenInfo {
1862        IntermediateTokenInfo {
1863            chain_id: 1,
1864            address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
1865                .parse::<Address>()
1866                .unwrap()
1867                .into(),
1868            decimals: 6,
1869            symbol: "USDC".into(),
1870            name: "USD Coin".into(),
1871            logo_url: None,
1872        }
1873    }
1874
1875    fn sample_request(kind: OrderKind) -> QuoteBridgeRequest {
1876        QuoteBridgeRequest {
1877            sell_chain_id: 1,
1878            buy_chain_id: 42_161,
1879            sell_token: Address::repeat_byte(0x11),
1880            sell_token_decimals: 18,
1881            buy_token: Address::repeat_byte(0x22).into(),
1882            buy_token_decimals: 6,
1883            sell_amount: U256::from(1_000_000u64),
1884            account: Address::repeat_byte(0x33),
1885            owner: None,
1886            receiver: None,
1887            bridge_recipient: None,
1888            slippage_bps: 50,
1889            bridge_slippage_bps: None,
1890            kind,
1891        }
1892    }
1893
1894    fn sample_outcome() -> SwapQuoteOutcome {
1895        SwapQuoteOutcome {
1896            sell_amount: U256::from(1_000_000u64),
1897            buy_amount_after_slippage: U256::from(999_500u64),
1898            fee_amount: U256::from(500u64),
1899            valid_to: 9_999_999,
1900            app_data_hex: "0xabc".into(),
1901            full_app_data: "{}".into(),
1902        }
1903    }
1904
1905    fn sample_bridge_response(provider_name: &str) -> QuoteBridgeResponse {
1906        QuoteBridgeResponse {
1907            provider: provider_name.to_owned(),
1908            sell_amount: U256::from(999_500u64),
1909            buy_amount: U256::from(998_000u64),
1910            fee_amount: U256::from(1_500u64),
1911            estimated_secs: 42,
1912            bridge_hook: None,
1913        }
1914    }
1915
1916    // ── Mock providers ───────────────────────────────────────────────────
1917
1918    /// A hook provider wired to return fixed bridge + unsigned-call data.
1919    struct MockHookProvider {
1920        info: BridgeProviderInfo,
1921        tokens: Vec<IntermediateTokenInfo>,
1922        bridge_response: QuoteBridgeResponse,
1923        unsigned_call: EvmCall,
1924        gas_limit: u64,
1925    }
1926
1927    impl BridgeProvider for MockHookProvider {
1928        fn info(&self) -> &BridgeProviderInfo {
1929            &self.info
1930        }
1931        fn supports_route(&self, _s: u64, _b: u64) -> bool {
1932            true
1933        }
1934        fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
1935            Box::pin(async { Ok(Vec::<BridgeNetworkInfo>::new()) })
1936        }
1937        fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
1938            let info = self.info.clone();
1939            Box::pin(
1940                async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
1941            )
1942        }
1943        fn get_intermediate_tokens<'a>(
1944            &'a self,
1945            _req: &'a QuoteBridgeRequest,
1946        ) -> IntermediateTokensFuture<'a> {
1947            let tokens = self.tokens.clone();
1948            Box::pin(async move { Ok(tokens) })
1949        }
1950        fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
1951            let response = self.bridge_response.clone();
1952            Box::pin(async move { Ok(response) })
1953        }
1954        fn get_bridging_params<'a>(
1955            &'a self,
1956            _c: u64,
1957            _o: &'a cow_orderbook::types::Order,
1958            _t: B256,
1959            _s: Option<Address>,
1960        ) -> BridgingParamsFuture<'a> {
1961            Box::pin(async { Ok(None) })
1962        }
1963        fn get_explorer_url(&self, _id: &str) -> String {
1964            String::new()
1965        }
1966        fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
1967            Box::pin(async {
1968                Ok(BridgeStatusResult {
1969                    status: BridgeStatus::Unknown,
1970                    fill_time_in_seconds: None,
1971                    deposit_tx_hash: None,
1972                    fill_tx_hash: None,
1973                })
1974            })
1975        }
1976        fn as_hook_bridge_provider(&self) -> Option<&dyn HookBridgeProvider> {
1977            Some(self)
1978        }
1979    }
1980
1981    impl HookBridgeProvider for MockHookProvider {
1982        fn get_unsigned_bridge_call<'a>(
1983            &'a self,
1984            _req: &'a QuoteBridgeRequest,
1985            _quote: &'a QuoteBridgeResponse,
1986        ) -> UnsignedCallFuture<'a> {
1987            let call = self.unsigned_call.clone();
1988            Box::pin(async move { Ok(call) })
1989        }
1990        fn get_gas_limit_estimation_for_hook<'a>(
1991            &'a self,
1992            _proxy_deployed: bool,
1993            _extra_gas: Option<u64>,
1994            _extra_gas_proxy_creation: Option<u64>,
1995        ) -> GasEstimationFuture<'a> {
1996            let gas = self.gas_limit;
1997            Box::pin(async move { Ok(gas) })
1998        }
1999        fn get_signed_hook<'a>(
2000            &'a self,
2001            _chain_id: cow_chains::SupportedChainId,
2002            _unsigned_call: &'a EvmCall,
2003            _nonce: &'a str,
2004            _deadline: u64,
2005            _gas: u64,
2006            _signer: &'a alloy_signer_local::PrivateKeySigner,
2007        ) -> SignedHookFuture<'a> {
2008            Box::pin(async {
2009                Err(cow_errors::CowError::Signing("not needed in PR #7 tests".into()))
2010            })
2011        }
2012    }
2013
2014    /// A receiver-account provider wired to return a fixed deposit address.
2015    struct MockReceiverProvider {
2016        info: BridgeProviderInfo,
2017        tokens: Vec<IntermediateTokenInfo>,
2018        bridge_response: QuoteBridgeResponse,
2019        deposit_address: String,
2020    }
2021
2022    impl BridgeProvider for MockReceiverProvider {
2023        fn info(&self) -> &BridgeProviderInfo {
2024            &self.info
2025        }
2026        fn supports_route(&self, _s: u64, _b: u64) -> bool {
2027            true
2028        }
2029        fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
2030            Box::pin(async { Ok(Vec::<BridgeNetworkInfo>::new()) })
2031        }
2032        fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
2033            let info = self.info.clone();
2034            Box::pin(
2035                async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
2036            )
2037        }
2038        fn get_intermediate_tokens<'a>(
2039            &'a self,
2040            _req: &'a QuoteBridgeRequest,
2041        ) -> IntermediateTokensFuture<'a> {
2042            let tokens = self.tokens.clone();
2043            Box::pin(async move { Ok(tokens) })
2044        }
2045        fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
2046            let response = self.bridge_response.clone();
2047            Box::pin(async move { Ok(response) })
2048        }
2049        fn get_bridging_params<'a>(
2050            &'a self,
2051            _c: u64,
2052            _o: &'a cow_orderbook::types::Order,
2053            _t: B256,
2054            _s: Option<Address>,
2055        ) -> BridgingParamsFuture<'a> {
2056            Box::pin(async { Ok(None) })
2057        }
2058        fn get_explorer_url(&self, _id: &str) -> String {
2059            String::new()
2060        }
2061        fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
2062            Box::pin(async {
2063                Ok(BridgeStatusResult {
2064                    status: BridgeStatus::Unknown,
2065                    fill_time_in_seconds: None,
2066                    deposit_tx_hash: None,
2067                    fill_tx_hash: None,
2068                })
2069            })
2070        }
2071        fn as_receiver_account_bridge_provider(
2072            &self,
2073        ) -> Option<&dyn ReceiverAccountBridgeProvider> {
2074            Some(self)
2075        }
2076    }
2077
2078    impl ReceiverAccountBridgeProvider for MockReceiverProvider {
2079        fn get_bridge_receiver_override<'a>(
2080            &'a self,
2081            _quote_request: &'a QuoteBridgeRequest,
2082            _quote_result: &'a QuoteBridgeResponse,
2083        ) -> ReceiverOverrideFuture<'a> {
2084            let addr = self.deposit_address.clone();
2085            Box::pin(async move { Ok(addr) })
2086        }
2087    }
2088
2089    /// A provider that implements neither sub-trait.
2090    struct MockUnknownProvider {
2091        info: BridgeProviderInfo,
2092    }
2093
2094    impl BridgeProvider for MockUnknownProvider {
2095        fn info(&self) -> &BridgeProviderInfo {
2096            &self.info
2097        }
2098        fn supports_route(&self, _s: u64, _b: u64) -> bool {
2099            true
2100        }
2101        fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
2102            Box::pin(async { Ok(Vec::<BridgeNetworkInfo>::new()) })
2103        }
2104        fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
2105            let info = self.info.clone();
2106            Box::pin(
2107                async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
2108            )
2109        }
2110        fn get_intermediate_tokens<'a>(
2111            &'a self,
2112            _req: &'a QuoteBridgeRequest,
2113        ) -> IntermediateTokensFuture<'a> {
2114            Box::pin(async { Ok(Vec::<IntermediateTokenInfo>::new()) })
2115        }
2116        fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
2117            Box::pin(async { Ok(sample_bridge_response("unknown")) })
2118        }
2119        fn get_bridging_params<'a>(
2120            &'a self,
2121            _c: u64,
2122            _o: &'a cow_orderbook::types::Order,
2123            _t: B256,
2124            _s: Option<Address>,
2125        ) -> BridgingParamsFuture<'a> {
2126            Box::pin(async { Ok(None) })
2127        }
2128        fn get_explorer_url(&self, _id: &str) -> String {
2129            String::new()
2130        }
2131        fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
2132            Box::pin(async {
2133                Ok(BridgeStatusResult {
2134                    status: BridgeStatus::Unknown,
2135                    fill_time_in_seconds: None,
2136                    deposit_tx_hash: None,
2137                    fill_tx_hash: None,
2138                })
2139            })
2140        }
2141    }
2142
2143    struct FixedQuoter {
2144        outcome: SwapQuoteOutcome,
2145        captured: std::sync::OnceLock<SwapQuoteParams>,
2146    }
2147
2148    impl SwapQuoter for FixedQuoter {
2149        fn quote_swap<'a>(&'a self, params: SwapQuoteParams) -> QuoteSwapFuture<'a> {
2150            self.captured.set(params).ok();
2151            let outcome = self.outcome.clone();
2152            Box::pin(async move { Ok(outcome) })
2153        }
2154    }
2155
2156    fn build_unsigned_call() -> EvmCall {
2157        EvmCall { to: Address::repeat_byte(0xAC), data: vec![0xde, 0xad], value: U256::ZERO }
2158    }
2159
2160    fn hook_params_with_metadata(metadata: Option<serde_json::Value>) -> GetQuoteWithBridgeParams {
2161        GetQuoteWithBridgeParams {
2162            swap_and_bridge_request: sample_request(OrderKind::Sell),
2163            slippage_bps: 50,
2164            advanced_settings_metadata: metadata,
2165            quote_signer: None,
2166            hook_deadline: None,
2167        }
2168    }
2169
2170    // ── Dispatcher tests ─────────────────────────────────────────────────
2171
2172    #[tokio::test]
2173    async fn get_quote_with_bridge_rejects_buy_orders() {
2174        let provider = MockHookProvider {
2175            info: hook_info(),
2176            tokens: vec![usdc()],
2177            bridge_response: sample_bridge_response("mock-hook"),
2178            unsigned_call: build_unsigned_call(),
2179            gas_limit: 500_000,
2180        };
2181        let quoter =
2182            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2183        let params = GetQuoteWithBridgeParams {
2184            swap_and_bridge_request: sample_request(OrderKind::Buy),
2185            slippage_bps: 50,
2186            advanced_settings_metadata: None,
2187            quote_signer: None,
2188            hook_deadline: None,
2189        };
2190        let err = get_quote_with_bridge(&params, &provider, &quoter).await.unwrap_err();
2191        assert!(matches!(err, BridgeError::OnlySellOrderSupported));
2192    }
2193
2194    #[tokio::test]
2195    async fn get_quote_with_bridge_dispatches_to_hook_branch() {
2196        let provider = MockHookProvider {
2197            info: hook_info(),
2198            tokens: vec![usdc()],
2199            bridge_response: sample_bridge_response("mock-hook"),
2200            unsigned_call: build_unsigned_call(),
2201            gas_limit: 500_000,
2202        };
2203        let quoter =
2204            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2205        let result = get_quote_with_bridge(&hook_params_with_metadata(None), &provider, &quoter)
2206            .await
2207            .unwrap();
2208        // Hook branch populates bridge_call_details, leaves override empty.
2209        assert!(result.bridge.bridge_call_details.is_some());
2210        assert!(result.bridge.bridge_receiver_override.is_none());
2211        assert_eq!(result.bridge.provider_info.name, "mock-hook");
2212    }
2213
2214    #[tokio::test]
2215    async fn get_quote_with_bridge_dispatches_to_receiver_branch() {
2216        let provider = MockReceiverProvider {
2217            info: receiver_info(),
2218            tokens: vec![usdc()],
2219            bridge_response: sample_bridge_response("mock-receiver"),
2220            deposit_address: "0xDEA00DEA00DEA00DEA00DEA00DEA00DEA00DEA000".into(),
2221        };
2222        let quoter =
2223            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2224        let result = get_quote_with_bridge(&hook_params_with_metadata(None), &provider, &quoter)
2225            .await
2226            .unwrap();
2227        // Receiver branch populates receiver_override, leaves call_details empty.
2228        assert!(result.bridge.bridge_call_details.is_none());
2229        assert_eq!(
2230            result.bridge.bridge_receiver_override.as_deref(),
2231            Some("0xDEA00DEA00DEA00DEA00DEA00DEA00DEA00DEA000"),
2232        );
2233    }
2234
2235    #[tokio::test]
2236    async fn get_quote_with_bridge_errors_on_unknown_provider_kind() {
2237        let provider = MockUnknownProvider { info: hook_info() };
2238        let quoter =
2239            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2240        let err = get_quote_with_bridge(&hook_params_with_metadata(None), &provider, &quoter)
2241            .await
2242            .unwrap_err();
2243        if let BridgeError::TxBuildError(msg) = err {
2244            assert!(msg.contains("implements neither"));
2245        } else {
2246            panic!("expected TxBuildError, got {err:?}");
2247        }
2248    }
2249
2250    // ── Metadata preservation (#852 at orchestration level) ──────────────
2251
2252    #[tokio::test]
2253    async fn hook_branch_preserves_caller_metadata_on_intermediate_swap() {
2254        let provider = MockHookProvider {
2255            info: hook_info(),
2256            tokens: vec![usdc()],
2257            bridge_response: sample_bridge_response("mock-hook"),
2258            unsigned_call: build_unsigned_call(),
2259            gas_limit: 500_000,
2260        };
2261        let quoter =
2262            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2263        let caller_meta = serde_json::json!({
2264            "partnerFee": { "bps": 25, "recipient": "0xpartner" },
2265        });
2266        let params = hook_params_with_metadata(Some(caller_meta));
2267
2268        get_quote_with_bridge(&params, &provider, &quoter).await.unwrap();
2269
2270        let captured = quoter.captured.get().cloned().expect("quoter called");
2271        let app_data: serde_json::Value =
2272            serde_json::from_str(&captured.app_data_json.unwrap()).unwrap();
2273        assert_eq!(app_data.pointer("/metadata/partnerFee/bps").and_then(|v| v.as_u64()), Some(25),);
2274        assert_eq!(
2275            app_data.pointer("/metadata/bridging/providerId").and_then(|v| v.as_str()),
2276            Some("cow-sdk://bridging/providers/mock-hook"),
2277        );
2278    }
2279
2280    // ── Simple flows ─────────────────────────────────────────────────────
2281
2282    #[tokio::test]
2283    async fn get_quote_without_bridge_calls_quoter_with_full_request() {
2284        let outcome = sample_outcome();
2285        let quoter = FixedQuoter { outcome: outcome.clone(), captured: std::sync::OnceLock::new() };
2286        let result =
2287            get_quote_without_bridge(&sample_request(OrderKind::Sell), &quoter).await.unwrap();
2288        assert_eq!(result.quote.sell_amount, outcome.sell_amount);
2289        assert_eq!(result.quote.buy_amount, outcome.buy_amount_after_slippage);
2290        assert_eq!(result.quote.fee_amount, outcome.fee_amount);
2291        assert_eq!(result.quote.provider, "same-chain");
2292
2293        let captured = quoter.captured.get().cloned().unwrap();
2294        // `get_quote_without_bridge` maps the *final* buy token directly —
2295        // no intermediate-token substitution.
2296        assert_eq!(captured.buy_token, sample_request(OrderKind::Sell).buy_token);
2297    }
2298
2299    #[tokio::test]
2300    async fn get_swap_quote_returns_provider_agnostic_response() {
2301        let outcome = sample_outcome();
2302        let quoter = FixedQuoter { outcome: outcome.clone(), captured: std::sync::OnceLock::new() };
2303        let resp = get_swap_quote(&sample_request(OrderKind::Sell), &quoter).await.unwrap();
2304        assert_eq!(resp.provider, "swap");
2305        assert_eq!(resp.buy_amount, outcome.buy_amount_after_slippage);
2306    }
2307
2308    #[tokio::test]
2309    async fn get_quote_without_bridge_propagates_quoter_error() {
2310        struct Failing;
2311        impl SwapQuoter for Failing {
2312            fn quote_swap<'a>(&'a self, _p: SwapQuoteParams) -> QuoteSwapFuture<'a> {
2313                Box::pin(async {
2314                    Err(cow_errors::CowError::Api { status: 502, body: "upstream".into() })
2315                })
2316            }
2317        }
2318        let err =
2319            get_quote_without_bridge(&sample_request(OrderKind::Sell), &Failing).await.unwrap_err();
2320        assert!(matches!(err, BridgeError::TxBuildError(_)));
2321    }
2322
2323    #[tokio::test]
2324    async fn get_swap_quote_propagates_quoter_error() {
2325        struct Failing;
2326        impl SwapQuoter for Failing {
2327            fn quote_swap<'a>(&'a self, _p: SwapQuoteParams) -> QuoteSwapFuture<'a> {
2328                Box::pin(async {
2329                    Err(cow_errors::CowError::Api { status: 500, body: "boom".into() })
2330                })
2331            }
2332        }
2333        let err = get_swap_quote(&sample_request(OrderKind::Sell), &Failing).await.unwrap_err();
2334        assert!(matches!(err, BridgeError::TxBuildError(_)));
2335    }
2336
2337    #[tokio::test]
2338    async fn get_quote_without_bridge_rejects_raw_buy_token() {
2339        // Same-chain swaps are EVM-only by construction — a Raw
2340        // buy_token must be rejected at the type-extraction boundary.
2341        let quoter =
2342            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2343        let mut req = sample_request(OrderKind::Sell);
2344        req.buy_token = crate::types::TokenAddress::Raw("bc1qanything".into());
2345        let err = get_quote_without_bridge(&req, &quoter).await.unwrap_err();
2346        let msg = err.to_string();
2347        assert!(matches!(err, BridgeError::TxBuildError(_)));
2348        assert!(msg.contains("EVM buy_token"), "unexpected err: {err}");
2349    }
2350
2351    #[tokio::test]
2352    async fn get_swap_quote_rejects_raw_buy_token() {
2353        let quoter =
2354            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2355        let mut req = sample_request(OrderKind::Sell);
2356        req.buy_token = crate::types::TokenAddress::Raw("SPL_MINT".into());
2357        let err = get_swap_quote(&req, &quoter).await.unwrap_err();
2358        assert!(matches!(err, BridgeError::TxBuildError(_)));
2359    }
2360
2361    #[test]
2362    fn get_cache_key_serialises_evm_and_raw_buy_tokens_differently() {
2363        let mut evm_req = sample_request(OrderKind::Sell);
2364        evm_req.buy_token = Address::repeat_byte(0x22).into();
2365        let evm_key = get_cache_key(&evm_req);
2366        assert!(evm_key.contains("0x22"));
2367        assert!(!evm_key.contains("raw:"));
2368
2369        let mut raw_req = sample_request(OrderKind::Sell);
2370        raw_req.buy_token = crate::types::TokenAddress::Raw("sol_mint_pubkey".into());
2371        let raw_key = get_cache_key(&raw_req);
2372        assert!(raw_key.contains("raw:sol_mint_pubkey"));
2373        assert_ne!(evm_key, raw_key);
2374    }
2375
2376    // ── Hook branch error paths ──────────────────────────────────────────
2377
2378    #[tokio::test]
2379    async fn hook_branch_propagates_gas_estimation_error() {
2380        /// Hook provider whose gas estimation fails.
2381        struct FailingGasProvider {
2382            info: BridgeProviderInfo,
2383            tokens: Vec<IntermediateTokenInfo>,
2384        }
2385        impl BridgeProvider for FailingGasProvider {
2386            fn info(&self) -> &BridgeProviderInfo {
2387                &self.info
2388            }
2389            fn supports_route(&self, _s: u64, _b: u64) -> bool {
2390                true
2391            }
2392            fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
2393                Box::pin(async { Ok(Vec::<BridgeNetworkInfo>::new()) })
2394            }
2395            fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
2396                let info = self.info.clone();
2397                Box::pin(
2398                    async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
2399                )
2400            }
2401            fn get_intermediate_tokens<'a>(
2402                &'a self,
2403                _req: &'a QuoteBridgeRequest,
2404            ) -> IntermediateTokensFuture<'a> {
2405                let tokens = self.tokens.clone();
2406                Box::pin(async move { Ok(tokens) })
2407            }
2408            fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
2409                Box::pin(async { Ok(sample_bridge_response("hook-failing-gas")) })
2410            }
2411            fn get_bridging_params<'a>(
2412                &'a self,
2413                _c: u64,
2414                _o: &'a cow_orderbook::types::Order,
2415                _t: B256,
2416                _s: Option<Address>,
2417            ) -> BridgingParamsFuture<'a> {
2418                Box::pin(async { Ok(None) })
2419            }
2420            fn get_explorer_url(&self, _id: &str) -> String {
2421                String::new()
2422            }
2423            fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
2424                Box::pin(async {
2425                    Ok(BridgeStatusResult {
2426                        status: BridgeStatus::Unknown,
2427                        fill_time_in_seconds: None,
2428                        deposit_tx_hash: None,
2429                        fill_tx_hash: None,
2430                    })
2431                })
2432            }
2433            fn as_hook_bridge_provider(&self) -> Option<&dyn HookBridgeProvider> {
2434                Some(self)
2435            }
2436        }
2437        impl HookBridgeProvider for FailingGasProvider {
2438            fn get_unsigned_bridge_call<'a>(
2439                &'a self,
2440                _req: &'a QuoteBridgeRequest,
2441                _quote: &'a QuoteBridgeResponse,
2442            ) -> UnsignedCallFuture<'a> {
2443                Box::pin(async {
2444                    Err(cow_errors::CowError::Api { status: 0, body: "not called".into() })
2445                })
2446            }
2447            fn get_gas_limit_estimation_for_hook<'a>(
2448                &'a self,
2449                _proxy_deployed: bool,
2450                _extra_gas: Option<u64>,
2451                _extra_gas_proxy_creation: Option<u64>,
2452            ) -> GasEstimationFuture<'a> {
2453                Box::pin(async {
2454                    Err(cow_errors::CowError::Api { status: 500, body: "gas oracle down".into() })
2455                })
2456            }
2457            fn get_signed_hook<'a>(
2458                &'a self,
2459                _chain_id: cow_chains::SupportedChainId,
2460                _unsigned_call: &'a EvmCall,
2461                _nonce: &'a str,
2462                _deadline: u64,
2463                _gas: u64,
2464                _signer: &'a alloy_signer_local::PrivateKeySigner,
2465            ) -> SignedHookFuture<'a> {
2466                Box::pin(async { Err(cow_errors::CowError::Signing("n/a".into())) })
2467            }
2468        }
2469
2470        let provider = FailingGasProvider { info: hook_info(), tokens: vec![usdc()] };
2471        exercise_bridge_surface(&provider).await;
2472        exercise_hook_bridge_surface(&provider).await;
2473        let quoter =
2474            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2475        let err = get_quote_with_bridge(&hook_params_with_metadata(None), &provider, &quoter)
2476            .await
2477            .unwrap_err();
2478        if let BridgeError::TxBuildError(msg) = err {
2479            assert!(msg.contains("gas oracle down"), "unexpected msg: {msg}");
2480        } else {
2481            panic!("expected TxBuildError, got {err:?}");
2482        }
2483    }
2484
2485    #[tokio::test]
2486    async fn hook_branch_propagates_unsigned_call_error() {
2487        /// Hook provider whose `get_unsigned_bridge_call` fails.
2488        struct FailingUnsignedCall {
2489            info: BridgeProviderInfo,
2490            tokens: Vec<IntermediateTokenInfo>,
2491        }
2492        impl BridgeProvider for FailingUnsignedCall {
2493            fn info(&self) -> &BridgeProviderInfo {
2494                &self.info
2495            }
2496            fn supports_route(&self, _s: u64, _b: u64) -> bool {
2497                true
2498            }
2499            fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
2500                Box::pin(async { Ok(Vec::<BridgeNetworkInfo>::new()) })
2501            }
2502            fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
2503                let info = self.info.clone();
2504                Box::pin(
2505                    async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
2506                )
2507            }
2508            fn get_intermediate_tokens<'a>(
2509                &'a self,
2510                _req: &'a QuoteBridgeRequest,
2511            ) -> IntermediateTokensFuture<'a> {
2512                let tokens = self.tokens.clone();
2513                Box::pin(async move { Ok(tokens) })
2514            }
2515            fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
2516                Box::pin(async { Ok(sample_bridge_response("hook-bad-calldata")) })
2517            }
2518            fn get_bridging_params<'a>(
2519                &'a self,
2520                _c: u64,
2521                _o: &'a cow_orderbook::types::Order,
2522                _t: B256,
2523                _s: Option<Address>,
2524            ) -> BridgingParamsFuture<'a> {
2525                Box::pin(async { Ok(None) })
2526            }
2527            fn get_explorer_url(&self, _id: &str) -> String {
2528                String::new()
2529            }
2530            fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
2531                Box::pin(async {
2532                    Ok(BridgeStatusResult {
2533                        status: BridgeStatus::Unknown,
2534                        fill_time_in_seconds: None,
2535                        deposit_tx_hash: None,
2536                        fill_tx_hash: None,
2537                    })
2538                })
2539            }
2540            fn as_hook_bridge_provider(&self) -> Option<&dyn HookBridgeProvider> {
2541                Some(self)
2542            }
2543        }
2544        impl HookBridgeProvider for FailingUnsignedCall {
2545            fn get_unsigned_bridge_call<'a>(
2546                &'a self,
2547                _req: &'a QuoteBridgeRequest,
2548                _quote: &'a QuoteBridgeResponse,
2549            ) -> UnsignedCallFuture<'a> {
2550                Box::pin(async {
2551                    Err(cow_errors::CowError::Api { status: 0, body: "bad calldata".into() })
2552                })
2553            }
2554            fn get_gas_limit_estimation_for_hook<'a>(
2555                &'a self,
2556                _proxy_deployed: bool,
2557                _extra_gas: Option<u64>,
2558                _extra_gas_proxy_creation: Option<u64>,
2559            ) -> GasEstimationFuture<'a> {
2560                Box::pin(async move { Ok(500_000u64) })
2561            }
2562            fn get_signed_hook<'a>(
2563                &'a self,
2564                _chain_id: cow_chains::SupportedChainId,
2565                _unsigned_call: &'a EvmCall,
2566                _nonce: &'a str,
2567                _deadline: u64,
2568                _gas: u64,
2569                _signer: &'a alloy_signer_local::PrivateKeySigner,
2570            ) -> SignedHookFuture<'a> {
2571                Box::pin(async { Err(cow_errors::CowError::Signing("n/a".into())) })
2572            }
2573        }
2574
2575        let provider = FailingUnsignedCall { info: hook_info(), tokens: vec![usdc()] };
2576        exercise_bridge_surface(&provider).await;
2577        exercise_hook_bridge_surface(&provider).await;
2578        let quoter =
2579            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2580        let err = get_quote_with_bridge(&hook_params_with_metadata(None), &provider, &quoter)
2581            .await
2582            .unwrap_err();
2583        if let BridgeError::TxBuildError(msg) = err {
2584            assert!(msg.contains("bad calldata"), "unexpected msg: {msg}");
2585        } else {
2586            panic!("expected TxBuildError, got {err:?}");
2587        }
2588    }
2589
2590    // ── Receiver branch error paths ──────────────────────────────────────
2591
2592    #[tokio::test]
2593    async fn receiver_branch_propagates_override_error() {
2594        /// Receiver-account provider whose `get_bridge_receiver_override` fails.
2595        struct FailingReceiverOverride {
2596            info: BridgeProviderInfo,
2597            tokens: Vec<IntermediateTokenInfo>,
2598        }
2599        impl BridgeProvider for FailingReceiverOverride {
2600            fn info(&self) -> &BridgeProviderInfo {
2601                &self.info
2602            }
2603            fn supports_route(&self, _s: u64, _b: u64) -> bool {
2604                true
2605            }
2606            fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
2607                Box::pin(async { Ok(Vec::<BridgeNetworkInfo>::new()) })
2608            }
2609            fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
2610                let info = self.info.clone();
2611                Box::pin(
2612                    async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
2613                )
2614            }
2615            fn get_intermediate_tokens<'a>(
2616                &'a self,
2617                _req: &'a QuoteBridgeRequest,
2618            ) -> IntermediateTokensFuture<'a> {
2619                let tokens = self.tokens.clone();
2620                Box::pin(async move { Ok(tokens) })
2621            }
2622            fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
2623                Box::pin(async { Ok(sample_bridge_response("receiver-failing-override")) })
2624            }
2625            fn get_bridging_params<'a>(
2626                &'a self,
2627                _c: u64,
2628                _o: &'a cow_orderbook::types::Order,
2629                _t: B256,
2630                _s: Option<Address>,
2631            ) -> BridgingParamsFuture<'a> {
2632                Box::pin(async { Ok(None) })
2633            }
2634            fn get_explorer_url(&self, _id: &str) -> String {
2635                String::new()
2636            }
2637            fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
2638                Box::pin(async {
2639                    Ok(BridgeStatusResult {
2640                        status: BridgeStatus::Unknown,
2641                        fill_time_in_seconds: None,
2642                        deposit_tx_hash: None,
2643                        fill_tx_hash: None,
2644                    })
2645                })
2646            }
2647            fn as_receiver_account_bridge_provider(
2648                &self,
2649            ) -> Option<&dyn ReceiverAccountBridgeProvider> {
2650                Some(self)
2651            }
2652        }
2653        impl ReceiverAccountBridgeProvider for FailingReceiverOverride {
2654            fn get_bridge_receiver_override<'a>(
2655                &'a self,
2656                _quote_request: &'a QuoteBridgeRequest,
2657                _quote_result: &'a QuoteBridgeResponse,
2658            ) -> ReceiverOverrideFuture<'a> {
2659                Box::pin(async {
2660                    Err(cow_errors::CowError::Api {
2661                        status: 0,
2662                        body: "no deposit addr available".into(),
2663                    })
2664                })
2665            }
2666        }
2667
2668        let provider = FailingReceiverOverride { info: receiver_info(), tokens: vec![usdc()] };
2669        exercise_bridge_surface(&provider).await;
2670        let quoter =
2671            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2672        let err = get_quote_with_bridge(&hook_params_with_metadata(None), &provider, &quoter)
2673            .await
2674            .unwrap_err();
2675        if let BridgeError::TxBuildError(msg) = err {
2676            assert!(msg.contains("no deposit addr available"), "unexpected msg: {msg}");
2677        } else {
2678            panic!("expected TxBuildError, got {err:?}");
2679        }
2680    }
2681
2682    // ── Hook branch — shape checks ────────────────────────────────────────
2683
2684    #[tokio::test]
2685    async fn hook_branch_bridge_call_details_carry_unsigned_call_bytes() {
2686        let provider = MockHookProvider {
2687            info: hook_info(),
2688            tokens: vec![usdc()],
2689            bridge_response: sample_bridge_response("mock-hook"),
2690            unsigned_call: build_unsigned_call(),
2691            gas_limit: 500_000,
2692        };
2693        let quoter =
2694            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2695        let result =
2696            get_quote_with_hook_bridge(&provider, &hook_params_with_metadata(None), &quoter)
2697                .await
2698                .unwrap();
2699        let details =
2700            result.bridge.bridge_call_details.expect("hook branch populates call_details");
2701        assert_eq!(details.unsigned_bridge_call.data, vec![0xde, 0xad]);
2702        assert_eq!(details.unsigned_bridge_call.to, Address::repeat_byte(0xAC),);
2703        // The pre-authorized hook uses the mocked post-hook (PR #7 leaves
2704        // the real signing for PR #8).
2705        assert_eq!(details.pre_authorized_bridging_hook.post_hook.gas_limit, "500000",);
2706    }
2707
2708    // ── Receiver branch — shape checks ───────────────────────────────────
2709
2710    #[tokio::test]
2711    async fn receiver_branch_sets_override_and_clears_call_details() {
2712        let provider = MockReceiverProvider {
2713            info: receiver_info(),
2714            tokens: vec![usdc()],
2715            bridge_response: sample_bridge_response("mock-receiver"),
2716            deposit_address: "TOPsolanaDepositAddrXXXXXXXXXXXXXXXXXXXXXXX".into(),
2717        };
2718        let quoter =
2719            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
2720        let result = get_quote_with_receiver_account_bridge(
2721            &provider,
2722            &hook_params_with_metadata(None),
2723            &quoter,
2724        )
2725        .await
2726        .unwrap();
2727        assert!(result.bridge.bridge_call_details.is_none());
2728        assert_eq!(
2729            result.bridge.bridge_receiver_override.as_deref(),
2730            Some("TOPsolanaDepositAddrXXXXXXXXXXXXXXXXXXXXXXX"),
2731        );
2732    }
2733
2734    // ── minimal_bridge_quote_result ──────────────────────────────────────
2735
2736    #[test]
2737    fn minimal_bridge_quote_result_wraps_response_amounts() {
2738        let req = sample_request(OrderKind::Sell);
2739        let resp = sample_bridge_response("arb");
2740        let quote = minimal_bridge_quote_result(&req, &resp);
2741        assert!(quote.is_sell);
2742        assert_eq!(quote.amounts_and_costs.after_fee.buy_amount, resp.buy_amount);
2743        // before_fee.buy_amount must equal buy_amount + fee.
2744        assert_eq!(
2745            quote.amounts_and_costs.before_fee.buy_amount,
2746            resp.buy_amount.saturating_add(resp.fee_amount),
2747        );
2748        assert_eq!(quote.fees.bridge_fee, resp.fee_amount);
2749        assert_eq!(quote.expected_fill_time_seconds, Some(resp.estimated_secs));
2750    }
2751
2752    #[test]
2753    fn minimal_bridge_quote_result_flags_buy_orders_as_non_sell() {
2754        let req = sample_request(OrderKind::Buy);
2755        let resp = sample_bridge_response("arb");
2756        let quote = minimal_bridge_quote_result(&req, &resp);
2757        assert!(!quote.is_sell);
2758    }
2759
2760    // ── get_bridge_signed_hook ───────────────────────────────────────────
2761
2762    /// Hook provider that captures calls to `get_signed_hook` so the
2763    /// test can inspect the derived nonce / deadline / gas limit.
2764    struct SigningCaptureProvider {
2765        info: BridgeProviderInfo,
2766        tokens: Vec<IntermediateTokenInfo>,
2767        bridge_response: QuoteBridgeResponse,
2768        unsigned_call: EvmCall,
2769        captured_nonce: std::sync::OnceLock<String>,
2770        captured_deadline: std::sync::OnceLock<u64>,
2771        captured_gas: std::sync::OnceLock<u64>,
2772    }
2773
2774    impl BridgeProvider for SigningCaptureProvider {
2775        fn info(&self) -> &BridgeProviderInfo {
2776            &self.info
2777        }
2778        fn supports_route(&self, _s: u64, _b: u64) -> bool {
2779            true
2780        }
2781        fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
2782            Box::pin(async { Ok(Vec::<BridgeNetworkInfo>::new()) })
2783        }
2784        fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
2785            let info = self.info.clone();
2786            Box::pin(
2787                async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
2788            )
2789        }
2790        fn get_intermediate_tokens<'a>(
2791            &'a self,
2792            _req: &'a QuoteBridgeRequest,
2793        ) -> IntermediateTokensFuture<'a> {
2794            let tokens = self.tokens.clone();
2795            Box::pin(async move { Ok(tokens) })
2796        }
2797        fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
2798            let resp = self.bridge_response.clone();
2799            Box::pin(async move { Ok(resp) })
2800        }
2801        fn get_bridging_params<'a>(
2802            &'a self,
2803            _c: u64,
2804            _o: &'a cow_orderbook::types::Order,
2805            _t: B256,
2806            _s: Option<Address>,
2807        ) -> BridgingParamsFuture<'a> {
2808            Box::pin(async { Ok(None) })
2809        }
2810        fn get_explorer_url(&self, _id: &str) -> String {
2811            String::new()
2812        }
2813        fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
2814            Box::pin(async {
2815                Ok(BridgeStatusResult {
2816                    status: BridgeStatus::Unknown,
2817                    fill_time_in_seconds: None,
2818                    deposit_tx_hash: None,
2819                    fill_tx_hash: None,
2820                })
2821            })
2822        }
2823        fn as_hook_bridge_provider(&self) -> Option<&dyn HookBridgeProvider> {
2824            Some(self)
2825        }
2826    }
2827
2828    impl HookBridgeProvider for SigningCaptureProvider {
2829        fn get_unsigned_bridge_call<'a>(
2830            &'a self,
2831            _req: &'a QuoteBridgeRequest,
2832            _quote: &'a QuoteBridgeResponse,
2833        ) -> UnsignedCallFuture<'a> {
2834            let call = self.unsigned_call.clone();
2835            Box::pin(async move { Ok(call) })
2836        }
2837        fn get_gas_limit_estimation_for_hook<'a>(
2838            &'a self,
2839            _proxy_deployed: bool,
2840            _extra_gas: Option<u64>,
2841            _extra_gas_proxy_creation: Option<u64>,
2842        ) -> GasEstimationFuture<'a> {
2843            Box::pin(async move { Ok(500_000u64) })
2844        }
2845        fn get_signed_hook<'a>(
2846            &'a self,
2847            _chain_id: cow_chains::SupportedChainId,
2848            _unsigned_call: &'a EvmCall,
2849            nonce: &'a str,
2850            deadline: u64,
2851            hook_gas_limit: u64,
2852            _signer: &'a alloy_signer_local::PrivateKeySigner,
2853        ) -> SignedHookFuture<'a> {
2854            self.captured_nonce.set(nonce.to_owned()).ok();
2855            self.captured_deadline.set(deadline).ok();
2856            self.captured_gas.set(hook_gas_limit).ok();
2857            Box::pin(async {
2858                Ok(BridgeHook {
2859                    post_hook: crate::utils::hook_mock_for_cost_estimation(500_000),
2860                    recipient: "0x0000000000000000000000000000000000000001".into(),
2861                })
2862            })
2863        }
2864    }
2865
2866    fn make_signer() -> alloy_signer_local::PrivateKeySigner {
2867        use std::str::FromStr;
2868        alloy_signer_local::PrivateKeySigner::from_str(
2869            "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
2870        )
2871        .unwrap()
2872    }
2873
2874    #[tokio::test]
2875    async fn get_bridge_signed_hook_threads_context_into_provider() {
2876        let provider = SigningCaptureProvider {
2877            info: hook_info(),
2878            tokens: vec![usdc()],
2879            bridge_response: sample_bridge_response("sig-capture"),
2880            unsigned_call: build_unsigned_call(),
2881            captured_nonce: std::sync::OnceLock::new(),
2882            captured_deadline: std::sync::OnceLock::new(),
2883            captured_gas: std::sync::OnceLock::new(),
2884        };
2885        let signer = make_signer();
2886        let ctx = GetBridgeSignedHookContext {
2887            signer: &signer,
2888            hook_gas_limit: 123_456,
2889            chain_id: cow_chains::SupportedChainId::Mainnet,
2890            deadline: 9_999_999,
2891        };
2892        let out =
2893            get_bridge_signed_hook(&provider, &sample_request(OrderKind::Sell), ctx).await.unwrap();
2894        // Gas + deadline must match what we threaded in.
2895        assert_eq!(*provider.captured_gas.get().unwrap(), 123_456);
2896        assert_eq!(*provider.captured_deadline.get().unwrap(), 9_999_999);
2897        // The nonce is keccak256(data || deadline_be) — deterministic.
2898        let expected = derive_hook_nonce(&out.unsigned_bridge_call.data, 9_999_999);
2899        assert_eq!(provider.captured_nonce.get().unwrap(), &expected);
2900        assert_eq!(out.bridging_quote.provider, "sig-capture");
2901    }
2902
2903    #[test]
2904    fn derive_hook_nonce_is_deterministic() {
2905        let data = vec![0xde, 0xad, 0xbe, 0xef];
2906        let a = derive_hook_nonce(&data, 42);
2907        let b = derive_hook_nonce(&data, 42);
2908        assert_eq!(a, b);
2909        assert!(a.starts_with("0x"));
2910        assert_eq!(a.len(), 2 + 64); // "0x" + 32 bytes hex
2911    }
2912
2913    #[test]
2914    fn derive_hook_nonce_changes_with_deadline() {
2915        let data = vec![0x01, 0x02];
2916        let a = derive_hook_nonce(&data, 42);
2917        let b = derive_hook_nonce(&data, 43);
2918        assert_ne!(a, b);
2919    }
2920
2921    #[test]
2922    fn derive_hook_nonce_changes_with_data() {
2923        let a = derive_hook_nonce(&[0x01], 42);
2924        let b = derive_hook_nonce(&[0x02], 42);
2925        assert_ne!(a, b);
2926    }
2927
2928    #[tokio::test]
2929    async fn get_bridge_signed_hook_propagates_quote_error() {
2930        /// Provider whose `get_quote` fails.
2931        struct QuoteFailing {
2932            info: BridgeProviderInfo,
2933        }
2934        impl BridgeProvider for QuoteFailing {
2935            fn info(&self) -> &BridgeProviderInfo {
2936                &self.info
2937            }
2938            fn supports_route(&self, _s: u64, _b: u64) -> bool {
2939                true
2940            }
2941            fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
2942                Box::pin(async { Ok(Vec::new()) })
2943            }
2944            fn get_buy_tokens<'a>(&'a self, _p: BuyTokensParams) -> BuyTokensFuture<'a> {
2945                let info = self.info.clone();
2946                Box::pin(
2947                    async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
2948                )
2949            }
2950            fn get_intermediate_tokens<'a>(
2951                &'a self,
2952                _req: &'a QuoteBridgeRequest,
2953            ) -> IntermediateTokensFuture<'a> {
2954                Box::pin(async { Ok(Vec::new()) })
2955            }
2956            fn get_quote<'a>(&'a self, _req: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
2957                Box::pin(async {
2958                    Err(cow_errors::CowError::Api { status: 500, body: "nope".into() })
2959                })
2960            }
2961            fn get_bridging_params<'a>(
2962                &'a self,
2963                _c: u64,
2964                _o: &'a cow_orderbook::types::Order,
2965                _t: B256,
2966                _s: Option<Address>,
2967            ) -> BridgingParamsFuture<'a> {
2968                Box::pin(async { Ok(None) })
2969            }
2970            fn get_explorer_url(&self, _id: &str) -> String {
2971                String::new()
2972            }
2973            fn get_status<'a>(&'a self, _id: &'a str, _c: u64) -> BridgeStatusFuture<'a> {
2974                Box::pin(async {
2975                    Ok(BridgeStatusResult {
2976                        status: BridgeStatus::Unknown,
2977                        fill_time_in_seconds: None,
2978                        deposit_tx_hash: None,
2979                        fill_tx_hash: None,
2980                    })
2981                })
2982            }
2983            fn as_hook_bridge_provider(&self) -> Option<&dyn HookBridgeProvider> {
2984                Some(self)
2985            }
2986        }
2987        impl HookBridgeProvider for QuoteFailing {
2988            fn get_unsigned_bridge_call<'a>(
2989                &'a self,
2990                _req: &'a QuoteBridgeRequest,
2991                _quote: &'a QuoteBridgeResponse,
2992            ) -> UnsignedCallFuture<'a> {
2993                Box::pin(async { Err(cow_errors::CowError::Signing("n/a".into())) })
2994            }
2995            fn get_gas_limit_estimation_for_hook<'a>(
2996                &'a self,
2997                _proxy_deployed: bool,
2998                _extra_gas: Option<u64>,
2999                _extra_gas_proxy_creation: Option<u64>,
3000            ) -> GasEstimationFuture<'a> {
3001                Box::pin(async move { Ok(500_000u64) })
3002            }
3003            fn get_signed_hook<'a>(
3004                &'a self,
3005                _chain_id: cow_chains::SupportedChainId,
3006                _unsigned_call: &'a EvmCall,
3007                _nonce: &'a str,
3008                _deadline: u64,
3009                _gas: u64,
3010                _signer: &'a alloy_signer_local::PrivateKeySigner,
3011            ) -> SignedHookFuture<'a> {
3012                Box::pin(async { Err(cow_errors::CowError::Signing("n/a".into())) })
3013            }
3014        }
3015
3016        let provider = QuoteFailing { info: hook_info() };
3017        // Confirm the trait downcasts return the expected variants — covers
3018        // the as_hook_bridge_provider arm on this mock provider.
3019        assert!(provider.as_hook_bridge_provider().is_some());
3020        assert!(provider.as_receiver_account_bridge_provider().is_none());
3021        exercise_bridge_surface(&provider).await;
3022        exercise_hook_bridge_surface(&provider).await;
3023        let signer = make_signer();
3024        let err = get_bridge_signed_hook(
3025            &provider,
3026            &sample_request(OrderKind::Sell),
3027            GetBridgeSignedHookContext {
3028                signer: &signer,
3029                hook_gas_limit: 1_000,
3030                chain_id: cow_chains::SupportedChainId::Mainnet,
3031                deadline: 1_234,
3032            },
3033        )
3034        .await
3035        .unwrap_err();
3036        if let BridgeError::TxBuildError(msg) = err {
3037            assert!(msg.contains("nope"), "unexpected: {msg}");
3038        } else {
3039            panic!("expected TxBuildError, got {err:?}");
3040        }
3041    }
3042
3043    // ── get_quote_with_hook_bridge with signer ───────────────────────────
3044
3045    #[tokio::test]
3046    async fn hook_branch_produces_real_hook_when_signer_provided() {
3047        let provider = SigningCaptureProvider {
3048            info: hook_info(),
3049            tokens: vec![usdc()],
3050            bridge_response: sample_bridge_response("with-signer"),
3051            unsigned_call: build_unsigned_call(),
3052            captured_nonce: std::sync::OnceLock::new(),
3053            captured_deadline: std::sync::OnceLock::new(),
3054            captured_gas: std::sync::OnceLock::new(),
3055        };
3056        let quoter =
3057            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
3058        let signer = std::sync::Arc::new(make_signer());
3059        let params = GetQuoteWithBridgeParams {
3060            swap_and_bridge_request: sample_request(OrderKind::Sell),
3061            slippage_bps: 50,
3062            advanced_settings_metadata: None,
3063            quote_signer: Some(std::sync::Arc::clone(&signer)),
3064            hook_deadline: Some(5_000_000),
3065        };
3066
3067        get_quote_with_hook_bridge(&provider, &params, &quoter).await.unwrap();
3068
3069        // The signer path must have threaded the caller's deadline
3070        // into get_signed_hook.
3071        assert_eq!(*provider.captured_deadline.get().unwrap(), 5_000_000);
3072    }
3073
3074    #[tokio::test]
3075    async fn hook_branch_defaults_deadline_to_u32_max_when_unset() {
3076        let provider = SigningCaptureProvider {
3077            info: hook_info(),
3078            tokens: vec![usdc()],
3079            bridge_response: sample_bridge_response("default-deadline"),
3080            unsigned_call: build_unsigned_call(),
3081            captured_nonce: std::sync::OnceLock::new(),
3082            captured_deadline: std::sync::OnceLock::new(),
3083            captured_gas: std::sync::OnceLock::new(),
3084        };
3085        let quoter =
3086            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
3087        let signer = std::sync::Arc::new(make_signer());
3088        let params = GetQuoteWithBridgeParams {
3089            swap_and_bridge_request: sample_request(OrderKind::Sell),
3090            slippage_bps: 50,
3091            advanced_settings_metadata: None,
3092            quote_signer: Some(std::sync::Arc::clone(&signer)),
3093            hook_deadline: None,
3094        };
3095
3096        get_quote_with_hook_bridge(&provider, &params, &quoter).await.unwrap();
3097        assert_eq!(*provider.captured_deadline.get().unwrap(), u64::from(u32::MAX));
3098    }
3099
3100    // ── Trait-surface coverage for module-level mocks ────────────────────
3101    //
3102    // The dispatcher tests only call the methods that the happy or
3103    // failure path needs; the remaining trait methods stay uncovered
3104    // otherwise. Each helper below exercises the full surface of one
3105    // mock so every impl row is hit.
3106
3107    async fn exercise_bridge_surface(provider: &dyn BridgeProvider) {
3108        assert_eq!(provider.name(), provider.info().name);
3109        _ = provider.supports_route(1, 2);
3110        _ = provider.get_networks().await;
3111        _ = provider
3112            .get_buy_tokens(BuyTokensParams {
3113                sell_chain_id: 1,
3114                buy_chain_id: 10,
3115                sell_token_address: None,
3116            })
3117            .await;
3118        _ = provider.get_intermediate_tokens(&sample_request(OrderKind::Sell)).await;
3119        _ = provider.get_quote(&sample_request(OrderKind::Sell)).await;
3120        let order = cow_orderbook::api::mock_get_order(&format!("0x{}", "aa".repeat(56)));
3121        _ = provider.get_bridging_params(1, &order, B256::ZERO, None).await;
3122        _ = provider.get_explorer_url("bridging-id");
3123        _ = provider.get_status("bridging-id", 1).await;
3124    }
3125
3126    async fn exercise_hook_bridge_surface(provider: &dyn HookBridgeProvider) {
3127        let req = sample_request(OrderKind::Sell);
3128        let quote = sample_bridge_response("surface");
3129        _ = provider.get_unsigned_bridge_call(&req, &quote).await;
3130        _ = provider.get_gas_limit_estimation_for_hook(true, Some(0), Some(0)).await;
3131        let signer = make_signer();
3132        let call = build_unsigned_call();
3133        _ = provider
3134            .get_signed_hook(cow_chains::SupportedChainId::Mainnet, &call, "nonce", 0, 0, &signer)
3135            .await;
3136    }
3137
3138    #[tokio::test]
3139    async fn mock_hook_provider_surface_is_callable() {
3140        let provider = MockHookProvider {
3141            info: hook_info(),
3142            tokens: vec![usdc()],
3143            bridge_response: sample_bridge_response("mock-hook"),
3144            unsigned_call: build_unsigned_call(),
3145            gas_limit: 500_000,
3146        };
3147        exercise_bridge_surface(&provider).await;
3148        assert!(provider.as_hook_bridge_provider().is_some());
3149        assert!(provider.as_receiver_account_bridge_provider().is_none());
3150        // Hit the placeholder `get_signed_hook` branch so its error path is covered.
3151        let signer = make_signer();
3152        let call = build_unsigned_call();
3153        let err = HookBridgeProvider::get_signed_hook(
3154            &provider,
3155            cow_chains::SupportedChainId::Mainnet,
3156            &call,
3157            "nonce",
3158            0,
3159            0,
3160            &signer,
3161        )
3162        .await
3163        .unwrap_err();
3164        assert!(matches!(err, cow_errors::CowError::Signing(_)));
3165    }
3166
3167    #[tokio::test]
3168    async fn mock_receiver_provider_surface_is_callable() {
3169        let provider = MockReceiverProvider {
3170            info: receiver_info(),
3171            tokens: vec![usdc()],
3172            bridge_response: sample_bridge_response("mock-receiver"),
3173            deposit_address: "0xDEA00DEA00DEA00DEA00DEA00DEA00DEA00DEA000".into(),
3174        };
3175        exercise_bridge_surface(&provider).await;
3176        assert!(provider.as_receiver_account_bridge_provider().is_some());
3177        assert!(provider.as_hook_bridge_provider().is_none());
3178    }
3179
3180    #[tokio::test]
3181    async fn mock_unknown_provider_surface_is_callable() {
3182        let provider = MockUnknownProvider { info: hook_info() };
3183        exercise_bridge_surface(&provider).await;
3184        assert!(provider.as_hook_bridge_provider().is_none());
3185        assert!(provider.as_receiver_account_bridge_provider().is_none());
3186    }
3187
3188    #[tokio::test]
3189    async fn signing_capture_provider_surface_is_callable() {
3190        let provider = SigningCaptureProvider {
3191            info: hook_info(),
3192            tokens: vec![usdc()],
3193            bridge_response: sample_bridge_response("sig-surface"),
3194            unsigned_call: build_unsigned_call(),
3195            captured_nonce: std::sync::OnceLock::new(),
3196            captured_deadline: std::sync::OnceLock::new(),
3197            captured_gas: std::sync::OnceLock::new(),
3198        };
3199        exercise_bridge_surface(&provider).await;
3200        assert!(provider.as_hook_bridge_provider().is_some());
3201    }
3202
3203    // ── Miscellaneous uncovered paths ────────────────────────────────────
3204
3205    #[test]
3206    fn get_quote_with_bridge_params_debug_is_concise() {
3207        let params = hook_params_with_metadata(Some(serde_json::json!({"k": "v"})));
3208        let dbg = format!("{params:?}");
3209        assert!(dbg.starts_with("GetQuoteWithBridgeParams"));
3210        assert!(dbg.contains("slippage_bps"));
3211        // The signer field must render as a boolean, not dump the key.
3212        let signer = std::sync::Arc::new(make_signer());
3213        let mut with_signer = hook_params_with_metadata(None);
3214        with_signer.quote_signer = Some(signer);
3215        let dbg_signed = format!("{with_signer:?}");
3216        assert!(dbg_signed.contains("quote_signer: true"));
3217    }
3218
3219    #[cfg(feature = "native")]
3220    #[tokio::test]
3221    async fn create_bridge_request_timeout_reports_prefix_and_duration() {
3222        let err = create_bridge_request_timeout(1, "TestProvider").await;
3223        let msg = err.to_string();
3224        assert!(msg.contains("TestProvider"));
3225        assert!(msg.contains("1ms"));
3226    }
3227
3228    #[cfg(feature = "native")]
3229    #[tokio::test]
3230    async fn fetch_multi_quote_runs_each_provider_and_sorts() {
3231        let mut sdk = BridgingSdk::new();
3232        sdk.add_provider(MockHookProvider {
3233            info: hook_info(),
3234            tokens: vec![usdc()],
3235            bridge_response: QuoteBridgeResponse {
3236                provider: "mock-hook".into(),
3237                sell_amount: U256::from(1_000u64),
3238                buy_amount: U256::from(900u64),
3239                fee_amount: U256::from(10u64),
3240                estimated_secs: 0,
3241                bridge_hook: None,
3242            },
3243            unsigned_call: build_unsigned_call(),
3244            gas_limit: 500_000,
3245        });
3246        sdk.add_provider(MockReceiverProvider {
3247            info: receiver_info(),
3248            tokens: vec![usdc()],
3249            bridge_response: QuoteBridgeResponse {
3250                provider: "mock-receiver".into(),
3251                sell_amount: U256::from(1_000u64),
3252                buy_amount: U256::from(950u64),
3253                fee_amount: U256::ZERO,
3254                estimated_secs: 0,
3255                bridge_hook: None,
3256            },
3257            deposit_address: "0x0".into(),
3258        });
3259        let results = fetch_multi_quote(&sdk, &sample_request(OrderKind::Sell), Some(20_000)).await;
3260        assert_eq!(results.len(), 2);
3261        // Best first — receiver has higher buy amount.
3262        assert_eq!(results[0].provider_dapp_id, "mock-receiver");
3263        assert_eq!(results[1].provider_dapp_id, "mock-hook");
3264    }
3265
3266    #[cfg(feature = "native")]
3267    #[tokio::test]
3268    async fn fetch_multi_quote_captures_provider_errors() {
3269        struct AlwaysFails {
3270            info: BridgeProviderInfo,
3271        }
3272        impl BridgeProvider for AlwaysFails {
3273            fn info(&self) -> &BridgeProviderInfo {
3274                &self.info
3275            }
3276            fn supports_route(&self, _: u64, _: u64) -> bool {
3277                true
3278            }
3279            fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
3280                Box::pin(async { Ok(Vec::new()) })
3281            }
3282            fn get_buy_tokens<'a>(&'a self, _: BuyTokensParams) -> BuyTokensFuture<'a> {
3283                let info = self.info.clone();
3284                Box::pin(
3285                    async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
3286                )
3287            }
3288            fn get_intermediate_tokens<'a>(
3289                &'a self,
3290                _: &'a QuoteBridgeRequest,
3291            ) -> IntermediateTokensFuture<'a> {
3292                Box::pin(async { Ok(Vec::new()) })
3293            }
3294            fn get_quote<'a>(&'a self, _: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
3295                Box::pin(async {
3296                    Err(cow_errors::CowError::Api { status: 500, body: "nope".into() })
3297                })
3298            }
3299            fn get_bridging_params<'a>(
3300                &'a self,
3301                _: u64,
3302                _: &'a cow_orderbook::types::Order,
3303                _: B256,
3304                _: Option<Address>,
3305            ) -> BridgingParamsFuture<'a> {
3306                Box::pin(async { Ok(None) })
3307            }
3308            fn get_explorer_url(&self, _: &str) -> String {
3309                String::new()
3310            }
3311            fn get_status<'a>(&'a self, _: &'a str, _: u64) -> BridgeStatusFuture<'a> {
3312                Box::pin(async { Ok(BridgeStatusResult::new(BridgeStatus::Unknown)) })
3313            }
3314        }
3315
3316        let provider_for_surface = AlwaysFails { info: hook_info() };
3317        exercise_bridge_surface(&provider_for_surface).await;
3318        let mut sdk = BridgingSdk::new();
3319        sdk.add_provider(AlwaysFails { info: hook_info() });
3320        let results = fetch_multi_quote(&sdk, &sample_request(OrderKind::Sell), None).await;
3321        assert_eq!(results.len(), 1);
3322        assert!(results[0].quote.is_none());
3323        assert!(results[0].error.as_deref().is_some_and(|e| e.contains("500")));
3324    }
3325
3326    #[cfg(feature = "native")]
3327    #[tokio::test]
3328    async fn execute_provider_quotes_reports_timeout_for_all_providers() {
3329        /// Provider whose quote never resolves — forces the global timeout branch.
3330        struct Slow {
3331            info: BridgeProviderInfo,
3332        }
3333        impl BridgeProvider for Slow {
3334            fn info(&self) -> &BridgeProviderInfo {
3335                &self.info
3336            }
3337            fn supports_route(&self, _: u64, _: u64) -> bool {
3338                true
3339            }
3340            fn get_networks<'a>(&'a self) -> NetworksFuture<'a> {
3341                Box::pin(async { Ok(Vec::new()) })
3342            }
3343            fn get_buy_tokens<'a>(&'a self, _: BuyTokensParams) -> BuyTokensFuture<'a> {
3344                let info = self.info.clone();
3345                Box::pin(
3346                    async move { Ok(GetProviderBuyTokens { provider_info: info, tokens: vec![] }) },
3347                )
3348            }
3349            fn get_intermediate_tokens<'a>(
3350                &'a self,
3351                _: &'a QuoteBridgeRequest,
3352            ) -> IntermediateTokensFuture<'a> {
3353                Box::pin(async { Ok(Vec::new()) })
3354            }
3355            // The body sleeps past the test budget, so the `Ok(...)`
3356            // arm and the `tokio::time::sleep` callsite never resolve in
3357            // a passing run.
3358            #[cfg_attr(coverage_nightly, coverage(off))]
3359            fn get_quote<'a>(&'a self, _: &'a QuoteBridgeRequest) -> QuoteFuture<'a> {
3360                Box::pin(async {
3361                    // Sleep far longer than the test's budget.
3362                    tokio::time::sleep(std::time::Duration::from_secs(60)).await;
3363                    Ok(QuoteBridgeResponse {
3364                        provider: "slow".into(),
3365                        sell_amount: U256::ZERO,
3366                        buy_amount: U256::ZERO,
3367                        fee_amount: U256::ZERO,
3368                        estimated_secs: 0,
3369                        bridge_hook: None,
3370                    })
3371                })
3372            }
3373            fn get_bridging_params<'a>(
3374                &'a self,
3375                _: u64,
3376                _: &'a cow_orderbook::types::Order,
3377                _: B256,
3378                _: Option<Address>,
3379            ) -> BridgingParamsFuture<'a> {
3380                Box::pin(async { Ok(None) })
3381            }
3382            fn get_explorer_url(&self, _: &str) -> String {
3383                String::new()
3384            }
3385            fn get_status<'a>(&'a self, _: &'a str, _: u64) -> BridgeStatusFuture<'a> {
3386                Box::pin(async { Ok(BridgeStatusResult::new(BridgeStatus::Unknown)) })
3387            }
3388        }
3389
3390        // Exercise the non-`get_quote` surface (which intentionally sleeps).
3391        let slow_for_surface = Slow { info: hook_info() };
3392        _ = slow_for_surface.info();
3393        _ = slow_for_surface.supports_route(1, 2);
3394        _ = slow_for_surface.get_networks().await;
3395        _ = slow_for_surface
3396            .get_buy_tokens(BuyTokensParams {
3397                sell_chain_id: 1,
3398                buy_chain_id: 10,
3399                sell_token_address: None,
3400            })
3401            .await;
3402        _ = slow_for_surface.get_intermediate_tokens(&sample_request(OrderKind::Sell)).await;
3403        let order = cow_orderbook::api::mock_get_order(&format!("0x{}", "aa".repeat(56)));
3404        _ = slow_for_surface.get_bridging_params(1, &order, B256::ZERO, None).await;
3405        _ = slow_for_surface.get_explorer_url("id");
3406        _ = slow_for_surface.get_status("id", 1).await;
3407
3408        let mut sdk = BridgingSdk::new();
3409        sdk.add_provider(Slow { info: hook_info() });
3410        let results = execute_provider_quotes(&sdk, &sample_request(OrderKind::Sell), 5).await;
3411        assert_eq!(results.len(), 1);
3412        let err = results[0].error.as_deref().unwrap_or_default();
3413        assert!(err.contains("Multi-quote timeout"), "unexpected err: {err}");
3414    }
3415
3416    #[test]
3417    fn safe_call_best_quote_callback_swallows_panic() {
3418        let result = MultiQuoteResult { provider_dapp_id: "cb".into(), quote: None, error: None };
3419        // None callback is a no-op (taken branch) — then panicking callback.
3420        safe_call_best_quote_callback::<fn(&MultiQuoteResult)>(None, &result);
3421        safe_call_best_quote_callback(Some(|_r: &MultiQuoteResult| panic!("boom")), &result);
3422    }
3423
3424    #[test]
3425    fn safe_call_progressive_callback_swallows_panic() {
3426        let result = MultiQuoteResult { provider_dapp_id: "cb".into(), quote: None, error: None };
3427        safe_call_progressive_callback::<fn(&MultiQuoteResult)>(None, &result);
3428        safe_call_progressive_callback(Some(|_r: &MultiQuoteResult| panic!("boom")), &result);
3429    }
3430
3431    #[tokio::test]
3432    async fn get_quote_with_hook_bridge_errors_on_unsupported_chain_id() {
3433        // Use a chain id that `SupportedChainId::try_from` can't resolve.
3434        let provider = MockHookProvider {
3435            info: hook_info(),
3436            tokens: vec![usdc()],
3437            bridge_response: sample_bridge_response("mock-hook"),
3438            unsigned_call: build_unsigned_call(),
3439            gas_limit: 500_000,
3440        };
3441        let quoter =
3442            FixedQuoter { outcome: sample_outcome(), captured: std::sync::OnceLock::new() };
3443        let mut req = sample_request(OrderKind::Sell);
3444        req.sell_chain_id = 999_999;
3445        let signer = std::sync::Arc::new(make_signer());
3446        let params = GetQuoteWithBridgeParams {
3447            swap_and_bridge_request: req,
3448            slippage_bps: 50,
3449            advanced_settings_metadata: None,
3450            quote_signer: Some(signer),
3451            hook_deadline: Some(1_234),
3452        };
3453        let err = get_quote_with_hook_bridge(&provider, &params, &quoter).await.unwrap_err();
3454        if let BridgeError::TxBuildError(msg) = err {
3455            assert!(msg.contains("unsupported sell_chain_id"), "unexpected err: {msg}",);
3456        } else {
3457            panic!("expected TxBuildError, got {err:?}");
3458        }
3459    }
3460}
3461
3462#[cfg(test)]
3463#[allow(clippy::tests_outside_test_module, reason = "inner module pattern")]
3464mod miscellaneous_coverage_tests {
3465    use alloy_primitives::Address;
3466
3467    #[test]
3468    fn test_helpers_expose_wallet_and_address() {
3469        use super::test_helpers::{get_wallet, test_address};
3470
3471        let wallet = get_wallet();
3472        // The wallet and the test address derive from the same key.
3473        assert_eq!(alloy_signer::Signer::address(&wallet), test_address());
3474        // Hardhat account #0.
3475        let expected: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".parse().unwrap();
3476        assert_eq!(test_address(), expected);
3477    }
3478
3479    #[test]
3480    fn intermediate_not_in_candidates_err_returns_tx_build_error() {
3481        use crate::types::BridgeError;
3482        let err = super::intermediate_not_in_candidates_err();
3483        assert!(
3484            matches!(&err, BridgeError::TxBuildError(m) if m.contains("not in candidates")),
3485            "got {err:?}"
3486        );
3487    }
3488
3489    #[test]
3490    fn intermediate_must_be_evm_err_returns_tx_build_error() {
3491        use crate::types::BridgeError;
3492        let err = super::intermediate_must_be_evm_err();
3493        assert!(
3494            matches!(&err, BridgeError::TxBuildError(m) if m.contains("must be EVM")),
3495            "got {err:?}"
3496        );
3497    }
3498}
3499
3500#[cfg(test)]
3501#[allow(clippy::tests_outside_test_module, reason = "inner module pattern")]
3502mod cross_chain_order_tests {
3503    use crate::{
3504        across::{EvmLogEntry, get_cow_trade_events},
3505        sdk::{GetCrossChainOrderParams, get_cross_chain_order},
3506    };
3507    use alloy_primitives::{Address, B256, U256, keccak256};
3508
3509    fn pad_u256(value: U256) -> [u8; 32] {
3510        value.to_be_bytes::<32>()
3511    }
3512
3513    fn left_pad_address(addr: Address) -> [u8; 32] {
3514        let mut buf = [0u8; 32];
3515        buf[12..32].copy_from_slice(addr.as_slice());
3516        buf
3517    }
3518
3519    struct DepositLogArgs {
3520        spoke_pool: Address,
3521        destination_chain: u64,
3522        deposit_id: u64,
3523        depositor: Address,
3524        recipient: Address,
3525        sell_token: Address,
3526        buy_token: Address,
3527    }
3528
3529    fn deposit_event_log(args: DepositLogArgs) -> EvmLogEntry {
3530        let DepositLogArgs {
3531            spoke_pool,
3532            destination_chain,
3533            deposit_id,
3534            depositor,
3535            recipient,
3536            sell_token,
3537            buy_token,
3538        } = args;
3539        // Topic 0 must match keccak256 of the canonical signature so the
3540        // filter in `get_across_deposit_events` accepts the log.
3541        let topic0 = keccak256(
3542            "FundsDeposited(bytes32,bytes32,uint256,uint256,uint256,uint256,uint32,uint32,uint32,bytes32,bytes32,bytes32,bytes)",
3543        );
3544        let topics = vec![
3545            topic0,
3546            B256::from(pad_u256(U256::from(destination_chain))),
3547            B256::from(pad_u256(U256::from(deposit_id))),
3548            B256::from(left_pad_address(depositor)),
3549        ];
3550        let mut data = Vec::with_capacity(9 * 32);
3551        data.extend_from_slice(&left_pad_address(sell_token)); // inputToken
3552        data.extend_from_slice(&left_pad_address(buy_token)); // outputToken
3553        data.extend_from_slice(&pad_u256(U256::from(1_000_000u64))); // inputAmount
3554        data.extend_from_slice(&pad_u256(U256::from(999_500u64))); // outputAmount
3555        data.extend_from_slice(&pad_u256(U256::from(1_700_000_000u64))); // quoteTimestamp
3556        data.extend_from_slice(&pad_u256(U256::from(1_700_999_999u64))); // fillDeadline
3557        data.extend_from_slice(&pad_u256(U256::ZERO)); // exclusivityDeadline
3558        data.extend_from_slice(&left_pad_address(recipient)); // recipient
3559        data.extend_from_slice(&left_pad_address(Address::ZERO)); // exclusiveRelayer
3560
3561        EvmLogEntry { address: spoke_pool, topics, data }
3562    }
3563
3564    fn cow_trade_log(
3565        settlement: Address,
3566        owner: Address,
3567        sell_token: Address,
3568        buy_token: Address,
3569        order_uid_bytes: &[u8],
3570    ) -> EvmLogEntry {
3571        let topic0 = keccak256("Trade(address,address,address,uint256,uint256,uint256,bytes)");
3572        let topics = vec![topic0, B256::from(left_pad_address(owner))];
3573        // 5 static words + offset + length + UID payload (rounded up to a 32-byte word).
3574        let uid_len = order_uid_bytes.len();
3575        let uid_padded_len = uid_len.div_ceil(32) * 32;
3576        let mut data = Vec::with_capacity(7 * 32 + uid_padded_len);
3577        data.extend_from_slice(&left_pad_address(sell_token)); // sellToken
3578        data.extend_from_slice(&left_pad_address(buy_token)); // buyToken
3579        data.extend_from_slice(&pad_u256(U256::from(1_000_000u64))); // sellAmount
3580        data.extend_from_slice(&pad_u256(U256::from(999_500u64))); // buyAmount
3581        data.extend_from_slice(&pad_u256(U256::ZERO)); // feeAmount
3582        // Offset to dynamic bytes — the next word starts at byte 192.
3583        data.extend_from_slice(&pad_u256(U256::from(6 * 32u64)));
3584        // Length of orderUid bytes.
3585        data.extend_from_slice(&pad_u256(U256::from(uid_len as u64)));
3586        data.extend_from_slice(order_uid_bytes);
3587        // Pad to a 32-byte boundary so the slice math in the parser stays valid.
3588        if !uid_len.is_multiple_of(32) {
3589            data.extend(vec![0u8; 32 - uid_len % 32]);
3590        }
3591        EvmLogEntry { address: settlement, topics, data }
3592    }
3593
3594    #[test]
3595    fn get_cross_chain_order_builds_order_when_logs_match() {
3596        // Build a synthetic deposit + matching trade event for mainnet so
3597        // `get_deposit_params` resolves the order — this is the only path
3598        // that exercises the success branch of `get_cross_chain_order`.
3599        use cow_chains::SupportedChainId;
3600        let chain_id = SupportedChainId::Mainnet.as_u64();
3601        let spoke_pool = crate::across::across_spoke_pool_addresses()[&chain_id];
3602        let settlement = cow_chains::settlement_contract(SupportedChainId::Mainnet);
3603
3604        let depositor = Address::repeat_byte(0x11);
3605        let recipient = Address::repeat_byte(0x22);
3606        let sell_token = Address::repeat_byte(0x33);
3607        let buy_token = Address::repeat_byte(0x44);
3608        let order_uid_bytes: Vec<u8> = (0..56u8).collect(); // 56-byte UID like CoW Protocol uses
3609        let order_uid_hex = format!("0x{}", alloy_primitives::hex::encode(&order_uid_bytes));
3610
3611        let logs = vec![
3612            cow_trade_log(settlement, depositor, sell_token, buy_token, &order_uid_bytes),
3613            deposit_event_log(DepositLogArgs {
3614                spoke_pool,
3615                destination_chain: 42_161,
3616                deposit_id: 7,
3617                depositor,
3618                recipient,
3619                sell_token,
3620                buy_token,
3621            }),
3622        ];
3623        // Sanity check: the trade log decodes to the expected order id.
3624        let trades = get_cow_trade_events(chain_id, &logs, None);
3625        assert_eq!(trades.len(), 1, "expected one trade event, got {trades:?}");
3626        assert_eq!(trades[0].order_uid, order_uid_hex);
3627
3628        let params = GetCrossChainOrderParams {
3629            chain_id,
3630            order_id: order_uid_hex,
3631            full_app_data: None,
3632            trade_tx_hash: "0xdeadbeef".to_owned(),
3633            logs: &logs,
3634            settlement_override: None,
3635        };
3636        let order = get_cross_chain_order(&params).expect("logs cover both event types");
3637        assert_eq!(order.chain_id, chain_id);
3638        assert_eq!(order.trade_tx_hash, "0xdeadbeef");
3639        assert!(order.explorer_url.is_none());
3640        assert_eq!(order.bridging_params.source_chain_id, chain_id);
3641        assert_eq!(order.bridging_params.destination_chain_id, 42_161);
3642        assert_eq!(order.bridging_params.bridging_id, "7");
3643        assert_eq!(order.bridging_params.recipient, recipient);
3644    }
3645}