diff --git a/.github/workflows/wasm-browser-test.yaml b/.github/workflows/wasm-browser-test.yaml index e09bc8895c..7f392245f2 100644 --- a/.github/workflows/wasm-browser-test.yaml +++ b/.github/workflows/wasm-browser-test.yaml @@ -40,4 +40,5 @@ jobs: wasm-pack test --headless --chrome --features browser-tests -- status::wasm::wasm_tests wasm-pack test --headless --chrome --features browser-tests -- retry::wasm_tests wasm-pack test --headless --chrome --features browser-tests -- raindex_client::local_db::wasm_tests + wasm-pack test --headless --chrome --features browser-tests -- raindex_client::local_db::query::fetch_order_trades::wasm_tests ' diff --git a/crates/common/src/local_db/query/fetch_order_trades/mod.rs b/crates/common/src/local_db/query/fetch_order_trades/mod.rs index 990242f8d6..d6311e402c 100644 --- a/crates/common/src/local_db/query/fetch_order_trades/mod.rs +++ b/crates/common/src/local_db/query/fetch_order_trades/mod.rs @@ -37,23 +37,78 @@ pub struct LocalDbOrderTrade { pub trade_id: String, } -/// Builds the SQL statement for retrieving order trades within the specified window. +const ORDER_HASH_CLAUSE: &str = "/*ORDER_HASH_CLAUSE*/"; +const ORDER_HASH_LIST_BODY: &str = "AND tws.order_hash IN ({list})"; +/// Match-NONE predicate emitted for an empty `These` filter. SQLite rejects the +/// degenerate `IN ()` form, so we splice a constant-false predicate that the +/// query optimizer prunes to zero rows. This makes "filter to exactly these +/// (none) hashes" return no rows — the deliberate opposite of `All`. +const ORDER_HASH_MATCH_NONE_BODY: &str = "AND 1=0"; + const START_TS_CLAUSE: &str = "/*START_TS_CLAUSE*/"; const START_TS_BODY: &str = "\n AND tws.block_timestamp >= {param}\n"; const END_TS_CLAUSE: &str = "/*END_TS_CLAUSE*/"; const END_TS_BODY: &str = "\n AND tws.block_timestamp <= {param}\n"; -pub fn build_fetch_order_trades_stmt( +/// Explicit selection of which orders' trades a fetch covers, so the builder +/// never has to read "all" out of an empty slice. +/// +/// - [`OrderHashFilter::All`] emits no order-hash predicate at all, so every +/// order's trades for the chain/raindex (within the optional time window) are +/// returned. +/// - [`OrderHashFilter::These`] filters to exactly the given hashes via +/// `WHERE order_hash IN (...)`. An empty slice means *none*: it emits a +/// match-NONE predicate and returns zero rows. It is the deliberate opposite +/// of `All`, not a synonym for it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderHashFilter<'a> { + /// No order-hash predicate: trades for every order are returned. + All, + /// Trades for exactly these order hashes. Empty = none (zero rows), never + /// all. + These(&'a [B256]), +} + +/// Builds the SQL statement for retrieving order trades within the specified +/// window. The `filter` explicitly selects which orders are covered, so trades +/// for one or many orders are fetched in a single query (eliminating the N+1 +/// query pattern and per-query connection overhead). The single-order path +/// passes `These(&[hash])`. +/// +/// The order-hash predicate rendered depends on `filter`: +/// - [`OrderHashFilter::All`] => no order-hash predicate (every order). +/// - [`OrderHashFilter::These`] non-empty => `AND order_hash IN (...)`. +/// - [`OrderHashFilter::These`] empty => `AND 1=0` (match nothing): an empty +/// `These` is *none*, never all. SQLite rejects `IN ()`, so the constant-false +/// predicate stands in for it. +pub fn build_fetch_order_trades_batch_stmt( raindex_id: &RaindexIdentifier, - order_hash: B256, + filter: OrderHashFilter<'_>, start_timestamp: Option, end_timestamp: Option, ) -> Result { let mut stmt = SqlStatement::new(QUERY_TEMPLATE); stmt.push(SqlValue::from(raindex_id.chain_id)); stmt.push(SqlValue::from(raindex_id.raindex_address)); - stmt.push(SqlValue::from(order_hash)); + match filter { + OrderHashFilter::All => { + // No order-hash predicate: drop the marker, keep all orders. + stmt.replace(ORDER_HASH_CLAUSE, "")?; + } + OrderHashFilter::These([]) => { + // Empty `These` means none. `IN ()` is invalid in SQLite, so splice + // a constant-false predicate that yields zero rows. + stmt.replace(ORDER_HASH_CLAUSE, ORDER_HASH_MATCH_NONE_BODY)?; + } + OrderHashFilter::These(hashes) => { + stmt.bind_list_clause( + ORDER_HASH_CLAUSE, + ORDER_HASH_LIST_BODY, + hashes.iter().copied().map(SqlValue::from), + )?; + } + } // Optional time filters let start_param = if let Some(v) = start_timestamp { @@ -91,53 +146,144 @@ mod tests { }; #[test] - fn builds_with_chain_id_and_filters() { - let order_hash = - b256!("0x00000000000000000000000000000000000000000000000000000000deadface"); - let stmt = build_fetch_order_trades_stmt( + fn batch_builds_in_clause_with_time_filters() { + let hash_a = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef"); + let hash_b = b256!("0x00000000000000000000000000000000000000000000000000000000deadface"); + let stmt = build_fetch_order_trades_batch_stmt( &RaindexIdentifier::new(137, Address::ZERO), - order_hash, + OrderHashFilter::These(&[hash_a, hash_b]), Some(11), Some(22), ) .unwrap(); - // Dynamic param clauses inserted + + // Marker replaced and an IN list (not an equality) is rendered. + assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE)); + assert!(stmt.sql.contains("tws.order_hash IN (?3, ?4)")); + assert!(!stmt.sql.contains("tws.order_hash = ")); + + // Time filters still bound after the order-hash list. assert!(!stmt.sql.contains(START_TS_CLAUSE)); assert!(!stmt.sql.contains(END_TS_CLAUSE)); - assert!(stmt.sql.contains("tws.block_timestamp >=")); - assert!(stmt.sql.contains("tws.block_timestamp <=")); - // First three fixed params: chain id (?1), raindex address (?2), order hash (?3) - assert_eq!(stmt.params.len(), 5); // includes start and end + assert!(stmt.sql.contains("tws.block_timestamp >= ?5")); + assert!(stmt.sql.contains("tws.block_timestamp <= ?6")); + + // Params: chain id, raindex, hash_a, hash_b, start, end + assert_eq!(stmt.params.len(), 6); assert_eq!(stmt.params[0], SqlValue::U64(137)); assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string())); - assert_eq!( - stmt.params[2], - SqlValue::Text(hex::encode_prefixed(order_hash)) - ); + assert_eq!(stmt.params[2], SqlValue::Text(hex::encode_prefixed(hash_a))); + assert_eq!(stmt.params[3], SqlValue::Text(hex::encode_prefixed(hash_b))); + assert_eq!(stmt.params[4], SqlValue::I64(11)); + assert_eq!(stmt.params[5], SqlValue::I64(22)); } #[test] - fn builds_without_time_filters_when_none() { - let order_hash = - b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef"); - let stmt = build_fetch_order_trades_stmt( + fn batch_single_hash_renders_single_placeholder_in_clause() { + let hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef"); + let stmt = build_fetch_order_trades_batch_stmt( &RaindexIdentifier::new(1, Address::ZERO), - order_hash, + OrderHashFilter::These(&[hash]), None, None, ) .unwrap(); + + assert!(stmt.sql.contains("tws.order_hash IN (?3)")); + // A one-element list never collapses to the old `= ?` equality form. + assert!(!stmt.sql.contains("tws.order_hash = ")); assert!(!stmt.sql.contains("tws.block_timestamp >=")); assert!(!stmt.sql.contains("tws.block_timestamp <=")); + assert_eq!(stmt.params.len(), 3); + // Fixed params: chain id (?1), raindex (?2), order hash (?3). + assert_eq!(stmt.params[0], SqlValue::U64(1)); + assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string())); + assert_eq!(stmt.params[2], SqlValue::Text(hex::encode_prefixed(hash))); + } + + #[test] + fn batch_single_hash_with_time_filters_binds_window() { + // The single-order path (a one-element slice) carries the same + // time-window binding semantics the removed single-order builder had: + // chain id (?1), raindex (?2), order hash (?3), start (?4), end (?5). + let hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadface"); + let stmt = build_fetch_order_trades_batch_stmt( + &RaindexIdentifier::new(137, Address::ZERO), + OrderHashFilter::These(&[hash]), + Some(11), + Some(22), + ) + .unwrap(); + + assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE)); assert!(!stmt.sql.contains(START_TS_CLAUSE)); assert!(!stmt.sql.contains(END_TS_CLAUSE)); - assert_eq!(stmt.params.len(), 3); - // Order of fixed params: chain id (?1), raindex (?2), order hash (?3) + assert!(stmt.sql.contains("tws.order_hash IN (?3)")); + assert!(!stmt.sql.contains("tws.order_hash = ")); + assert!(stmt.sql.contains("tws.block_timestamp >= ?4")); + assert!(stmt.sql.contains("tws.block_timestamp <= ?5")); + + assert_eq!(stmt.params.len(), 5); + assert_eq!(stmt.params[0], SqlValue::U64(137)); + assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string())); + assert_eq!(stmt.params[2], SqlValue::Text(hex::encode_prefixed(hash))); + assert_eq!(stmt.params[3], SqlValue::I64(11)); + assert_eq!(stmt.params[4], SqlValue::I64(22)); + } + + #[test] + fn all_emits_no_order_hash_predicate() { + // `All` is the only variant that drops the order-hash predicate: no IN + // list and no match-NONE constant. Every order is returned. + let stmt = build_fetch_order_trades_batch_stmt( + &RaindexIdentifier::new(1, Address::ZERO), + OrderHashFilter::All, + None, + None, + ) + .unwrap(); + + // The marker is consumed and *no* order-hash predicate is rendered (the + // SELECT list still projects tws.order_hash, so only the predicate forms + // are asserted absent). + assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE)); + assert!(!stmt.sql.contains("tws.order_hash IN (")); + assert!(!stmt.sql.contains("tws.order_hash = ")); + // Crucially, `All` does NOT emit the match-NONE predicate that empty + // `These` does — the two are opposites. + assert!(!stmt.sql.contains("1=0")); + // Only the two fixed params (chain id, raindex) remain. + assert_eq!(stmt.params.len(), 2); + assert_eq!(stmt.params[0], SqlValue::U64(1)); + assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string())); + } + + #[test] + fn these_empty_emits_match_none_predicate_not_dropped_clause() { + // An empty `These` means *none*: it emits the constant-false predicate + // `AND 1=0` so zero rows match. This is the deliberate opposite of `All` + // (which would drop the clause and return every order). It must NOT + // degenerate to "all". + let stmt = build_fetch_order_trades_batch_stmt( + &RaindexIdentifier::new(1, Address::ZERO), + OrderHashFilter::These(&[]), + None, + None, + ) + .unwrap(); + + // The match-NONE predicate is present... + assert!(stmt.sql.contains("1=0")); + // ...and the marker is consumed (not left unsubstituted). + assert!(!stmt.sql.contains(ORDER_HASH_CLAUSE)); + // No IN list / equality predicate and no bound hashes: empty These binds + // zero placeholders. + assert!(!stmt.sql.contains("tws.order_hash IN (")); + assert!(!stmt.sql.contains("tws.order_hash = ")); + // Only the two fixed params (chain id, raindex) — the constant-false + // predicate binds nothing. + assert_eq!(stmt.params.len(), 2); assert_eq!(stmt.params[0], SqlValue::U64(1)); assert_eq!(stmt.params[1], SqlValue::Text(Address::ZERO.to_string())); - assert_eq!( - stmt.params[2], - SqlValue::Text(hex::encode_prefixed(order_hash)) - ); } } diff --git a/crates/common/src/local_db/query/fetch_order_trades/query.sql b/crates/common/src/local_db/query/fetch_order_trades/query.sql index 79bde7259e..b679529d4d 100644 --- a/crates/common/src/local_db/query/fetch_order_trades/query.sql +++ b/crates/common/src/local_db/query/fetch_order_trades/query.sql @@ -4,7 +4,7 @@ WITH filtered_trades AS ( FROM derived_trades tws WHERE tws.chain_id = ?1 AND tws.raindex_address = ?2 - AND tws.order_hash = ?3 + /*ORDER_HASH_CLAUSE*/ /*START_TS_CLAUSE*/ /*END_TS_CLAUSE*/ ORDER BY tws.block_timestamp DESC, tws.block_number DESC, tws.log_index DESC, tws.trade_kind, tws.trade_side diff --git a/crates/common/src/raindex_client/local_db/query/fetch_order_trades.rs b/crates/common/src/raindex_client/local_db/query/fetch_order_trades.rs index 74ecf2866b..38adfc4711 100644 --- a/crates/common/src/raindex_client/local_db/query/fetch_order_trades.rs +++ b/crates/common/src/raindex_client/local_db/query/fetch_order_trades.rs @@ -1,10 +1,17 @@ use crate::local_db::query::fetch_order_trades::{ - build_fetch_order_trades_stmt, LocalDbOrderTrade, + build_fetch_order_trades_batch_stmt, LocalDbOrderTrade, OrderHashFilter, }; use crate::local_db::query::{LocalDbQueryError, LocalDbQueryExecutor}; use crate::local_db::RaindexIdentifier; use alloy::primitives::B256; +/// Fetches the trades for a single order hash within the optional time window. +/// +/// This is the single-order convenience wrapper over +/// [`fetch_order_trades_batch`]: it delegates to the batched fetcher with a +/// one-element hash slice, so the batched `WHERE order_hash IN (...)` query is +/// the single underlying path. Because the result is already filtered to the +/// one requested hash, the batch result is returned as-is. pub async fn fetch_order_trades( exec: &E, raindex_id: &RaindexIdentifier, @@ -12,24 +19,133 @@ pub async fn fetch_order_trades( start_timestamp: Option, end_timestamp: Option, ) -> Result, LocalDbQueryError> { - let stmt = - build_fetch_order_trades_stmt(raindex_id, order_hash, start_timestamp, end_timestamp)?; + fetch_order_trades_batch( + exec, + raindex_id, + &[order_hash], + start_timestamp, + end_timestamp, + ) + .await +} + +/// Batched variant of [`fetch_order_trades`]: fetches trades for many order +/// hashes in a single `WHERE order_hash IN (...)` query, avoiding the N+1 +/// pattern (and per-query connection overhead) of looping the single-hash +/// fetcher. Returns an empty vec for an empty input without touching the DB. +pub async fn fetch_order_trades_batch( + exec: &E, + raindex_id: &RaindexIdentifier, + order_hashes: &[B256], + start_timestamp: Option, + end_timestamp: Option, +) -> Result, LocalDbQueryError> { + // Performance optimization, not a correctness guard: `These(&[])` already + // builds a valid match-NONE query that returns zero rows, so this is the + // same result the DB would give — we just skip the round-trip when we + // already know the answer is empty. + if order_hashes.is_empty() { + return Ok(Vec::new()); + } + let stmt = build_fetch_order_trades_batch_stmt( + raindex_id, + OrderHashFilter::These(order_hashes), + start_timestamp, + end_timestamp, + )?; exec.query_json(&stmt).await } -#[cfg(all(test, target_family = "wasm"))] +#[cfg(all(test, target_family = "wasm", feature = "browser-tests"))] mod wasm_tests { use super::*; use crate::raindex_client::local_db::executor::tests::create_sql_capturing_callback; use crate::raindex_client::local_db::executor::JsCallbackExecutor; - use alloy::primitives::{b256, Address}; + use alloy::primitives::{address, b256, hex, Address, U256}; + use js_sys::{Array, Function}; use std::cell::RefCell; use std::rc::Rc; + use wasm_bindgen::prelude::Closure; use wasm_bindgen_test::*; use wasm_bindgen_utils::prelude::*; + /// Builds a `LocalDbOrderTrade` for a given order hash so a test callback + /// can return realistic, per-order rows (the only field that varies between + /// orders for our discrimination is `order_hash` + `trade_id`). + fn trade_row(order_hash: B256, trade_id: &str, block_timestamp: u64) -> LocalDbOrderTrade { + LocalDbOrderTrade { + chain_id: 111, + trade_kind: "TakeOrder".to_string(), + raindex: address!("0x7777777777777777777777777777777777777777"), + order_hash, + order_owner: address!("0x00000000000000000000000000000000000000aa"), + order_nonce: "0x01".to_string(), + transaction_hash: b256!( + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ), + log_index: 0, + block_number: 10, + block_timestamp, + transaction_sender: address!("0x00000000000000000000000000000000000000bb"), + input_vault_id: U256::from(1u64), + input_token: address!("0x00000000000000000000000000000000000000a1"), + input_token_name: Some("Token A".to_string()), + input_token_symbol: Some("TKA".to_string()), + input_token_decimals: Some(18), + input_delta: "0x01".to_string(), + input_running_balance: Some("0x02".to_string()), + output_vault_id: U256::from(2u64), + output_token: address!("0x00000000000000000000000000000000000000b1"), + output_token_name: Some("Token B".to_string()), + output_token_symbol: Some("TKB".to_string()), + output_token_decimals: Some(6), + output_delta: "0x03".to_string(), + output_running_balance: Some("0x04".to_string()), + trade_id: trade_id.to_string(), + } + } + + /// A callback that mimics the SQLite `WHERE order_hash IN (...)` filter: + /// it scans the *bound params* for each known order hash and returns only + /// the seeded rows whose hash actually appears in the params. This makes the + /// callback behave like the real DB — the wrapper only gets a given order's + /// rows if it actually bound that order's hash into the batch query. + fn order_hash_filtering_callback(seeded: Vec) -> Function { + let closure = Closure::wrap(Box::new(move |_sql: String, params: JsValue| -> JsValue { + let mut bound: Vec = Vec::new(); + if Array::is_array(¶ms) { + let arr = Array::from(¶ms); + for i in 0..arr.length() { + if let Some(s) = arr.get(i).as_string() { + bound.push(s); + } + } + } + let matching: Vec<&LocalDbOrderTrade> = seeded + .iter() + .filter(|t| { + bound + .iter() + .any(|p| p == &hex::encode_prefixed(t.order_hash)) + }) + .collect(); + let json = serde_json::to_string(&matching).unwrap(); + let result = WasmEncodedResult::Success:: { + value: json, + error: None, + }; + serde_wasm_bindgen::to_value(&result).unwrap() + }) as Box JsValue>); + let func: Function = closure.as_ref().clone().unchecked_into(); + closure.forget(); + func + } + #[wasm_bindgen_test] - async fn wrapper_uses_builder_sql_exactly() { + async fn wrapper_uses_batch_builder_sql_exactly() { + // The single-order wrapper delegates to the batched fetcher with a + // one-element hash slice, so the SQL it emits is exactly the batch + // builder's `IN (...)` query (not the legacy `= ?3` form). let chain_id = 111; let raindex = Address::from([0x77; 20]); let order_hash = @@ -37,13 +153,16 @@ mod wasm_tests { let start = Some(100); let end = Some(200); - let expected_stmt = build_fetch_order_trades_stmt( + let expected_stmt = build_fetch_order_trades_batch_stmt( &RaindexIdentifier::new(chain_id, raindex), - order_hash.clone(), + OrderHashFilter::These(&[order_hash]), start, end, ) .unwrap(); + // The delegated SQL is the single-element IN form, not an equality. + assert!(expected_stmt.sql.contains("tws.order_hash IN (?3)")); + assert!(!expected_stmt.sql.contains("tws.order_hash = ")); let store = Rc::new(RefCell::new(( String::new(), @@ -65,4 +184,121 @@ mod wasm_tests { let captured = store.borrow().clone(); assert_eq!(captured.0, expected_stmt.sql); } + + #[wasm_bindgen_test] + async fn batch_wrapper_uses_builder_sql_exactly() { + let chain_id = 111; + let raindex = Address::from([0x77; 20]); + let hash_a = b256!("0x000000000000000000000000000000000000000000000000000000000000abcd"); + let hash_b = b256!("0x000000000000000000000000000000000000000000000000000000000000ef01"); + let start = Some(100); + let end = Some(200); + + let expected_stmt = build_fetch_order_trades_batch_stmt( + &RaindexIdentifier::new(chain_id, raindex), + OrderHashFilter::These(&[hash_a, hash_b]), + start, + end, + ) + .unwrap(); + + let store = Rc::new(RefCell::new(( + String::new(), + wasm_bindgen::JsValue::UNDEFINED, + ))); + let callback = create_sql_capturing_callback("[]", store.clone()); + let exec = JsCallbackExecutor::from_ref(&callback); + + let res = super::fetch_order_trades_batch( + &exec, + &RaindexIdentifier::new(chain_id, raindex), + &[hash_a, hash_b], + start, + end, + ) + .await; + assert!(res.is_ok()); + + let captured = store.borrow().clone(); + assert_eq!(captured.0, expected_stmt.sql); + } + + #[wasm_bindgen_test] + async fn batch_wrapper_short_circuits_empty_input() { + let chain_id = 111; + let raindex = Address::from([0x77; 20]); + + let store = Rc::new(RefCell::new(( + String::new(), + wasm_bindgen::JsValue::UNDEFINED, + ))); + let callback = create_sql_capturing_callback("[]", store.clone()); + let exec = JsCallbackExecutor::from_ref(&callback); + + let res = super::fetch_order_trades_batch( + &exec, + &RaindexIdentifier::new(chain_id, raindex), + &[], + None, + None, + ) + .await + .unwrap(); + + // No DB call is made and an empty result is returned. + assert!(res.is_empty()); + assert_eq!(store.borrow().0, String::new()); + } + + /// The single-order wrapper (used in production by `LocalDbOrders::trades_list`) + /// now runs over the batch builder. This proves it returns exactly the + /// requested order's trades — the same rows the batch path returns for a + /// one-element list — and never the wrong order's rows, both orders' rows, + /// or an empty result. + #[wasm_bindgen_test] + async fn single_order_wrapper_returns_only_that_orders_trades_via_batch() { + let chain_id = 111; + let raindex = address!("0x7777777777777777777777777777777777777777"); + let hash_a = b256!("0x000000000000000000000000000000000000000000000000000000000000aaaa"); + let hash_b = b256!("0x000000000000000000000000000000000000000000000000000000000000bbbb"); + + // Two trades for order A, one for order B. + let a1 = trade_row(hash_a, "trade-a1", 100); + let a2 = trade_row(hash_a, "trade-a2", 150); + let b1 = trade_row(hash_b, "trade-b1", 120); + let seeded = vec![a1.clone(), a2.clone(), b1.clone()]; + + let callback = order_hash_filtering_callback(seeded); + let exec = JsCallbackExecutor::from_ref(&callback); + let raindex_id = RaindexIdentifier::new(chain_id, raindex); + + // Single-order wrapper for A: must return exactly A's two trades. + let via_wrapper = super::fetch_order_trades(&exec, &raindex_id, hash_a, None, None) + .await + .unwrap(); + // Distinguishes correct (2 rows for A) from empty / cross-order (B) / + // both-orders (3 rows) results. + assert_eq!(via_wrapper.len(), 2); + assert!(via_wrapper.iter().all(|t| t.order_hash == hash_a)); + assert!(via_wrapper.iter().any(|t| t.trade_id == "trade-a1")); + assert!(via_wrapper.iter().any(|t| t.trade_id == "trade-a2")); + assert!(via_wrapper.iter().all(|t| t.order_hash != hash_b)); + assert!(via_wrapper.iter().all(|t| t.trade_id != "trade-b1")); + + // The batch path with the same one-element list returns the identical + // rows: the single-order wrapper is now a faithful alias of the batch fn. + let via_batch = super::fetch_order_trades_batch(&exec, &raindex_id, &[hash_a], None, None) + .await + .unwrap(); + assert_eq!(via_batch, via_wrapper); + + // And asking the wrapper for B returns only B's single trade, never A's + // — proving the bound hash actually drives the result. + let only_b = super::fetch_order_trades(&exec, &raindex_id, hash_b, None, None) + .await + .unwrap(); + assert_eq!(only_b.len(), 1); + assert_eq!(only_b[0].order_hash, hash_b); + assert_eq!(only_b[0].trade_id, "trade-b1"); + } }