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 f837a23817..803484e260 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 @@ -9,6 +9,7 @@ const QUERY_TEMPLATE: &str = include_str!("query.sql"); #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct LocalDbOrderTrade { + pub chain_id: u32, pub trade_kind: String, pub orderbook: Address, pub order_hash: B256, 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 d50b5392f4..8468390adf 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 @@ -289,6 +289,7 @@ trade_with_snapshots AS ( AND mvb_out.vault_id = tr.output_vault_id ) SELECT + tws.chain_id, tws.trade_kind, tws.orderbook_address AS orderbook, tws.order_hash, diff --git a/crates/common/src/local_db/query/fetch_trades_by_tx/mod.rs b/crates/common/src/local_db/query/fetch_trades_by_tx/mod.rs new file mode 100644 index 0000000000..bde5dc730a --- /dev/null +++ b/crates/common/src/local_db/query/fetch_trades_by_tx/mod.rs @@ -0,0 +1,120 @@ +use crate::local_db::query::{SqlBuildError, SqlStatement, SqlValue}; +use alloy::primitives::{Address, B256}; + +const QUERY_TEMPLATE: &str = include_str!("query.sql"); + +const TAKE_ORDERS_CHAIN_IDS_CLAUSE: &str = "/*TAKE_ORDERS_CHAIN_IDS_CLAUSE*/"; +const TAKE_ORDERS_CHAIN_IDS_CLAUSE_BODY: &str = "AND t.chain_id IN ({list})"; +const TAKE_ORDERS_ORDERBOOKS_CLAUSE: &str = "/*TAKE_ORDERS_ORDERBOOKS_CLAUSE*/"; +const TAKE_ORDERS_ORDERBOOKS_CLAUSE_BODY: &str = "AND t.orderbook_address IN ({list})"; + +const CLEAR_EVENTS_CHAIN_IDS_CLAUSE: &str = "/*CLEAR_EVENTS_CHAIN_IDS_CLAUSE*/"; +const CLEAR_EVENTS_CHAIN_IDS_CLAUSE_BODY: &str = "AND c.chain_id IN ({list})"; +const CLEAR_EVENTS_ORDERBOOKS_CLAUSE: &str = "/*CLEAR_EVENTS_ORDERBOOKS_CLAUSE*/"; +const CLEAR_EVENTS_ORDERBOOKS_CLAUSE_BODY: &str = "AND c.orderbook_address IN ({list})"; + +#[derive(Debug, Clone)] +pub struct FetchTradesByTxArgs { + pub chain_ids: Vec, + pub orderbook_addresses: Vec
, + pub tx_hash: B256, +} + +pub fn build_fetch_trades_by_tx_stmt( + args: &FetchTradesByTxArgs, +) -> Result { + let mut stmt = SqlStatement::new(QUERY_TEMPLATE); + + stmt.push(SqlValue::from(args.tx_hash)); + + let mut chain_ids = args.chain_ids.clone(); + chain_ids.sort_unstable(); + chain_ids.dedup(); + + let mut orderbooks = args.orderbook_addresses.clone(); + orderbooks.sort(); + orderbooks.dedup(); + + let chain_ids_iter = || chain_ids.iter().cloned().map(SqlValue::from); + let orderbooks_iter = || orderbooks.iter().cloned().map(SqlValue::from); + + stmt.bind_list_clause( + TAKE_ORDERS_CHAIN_IDS_CLAUSE, + TAKE_ORDERS_CHAIN_IDS_CLAUSE_BODY, + chain_ids_iter(), + )?; + stmt.bind_list_clause( + CLEAR_EVENTS_CHAIN_IDS_CLAUSE, + CLEAR_EVENTS_CHAIN_IDS_CLAUSE_BODY, + chain_ids_iter(), + )?; + stmt.bind_list_clause( + TAKE_ORDERS_ORDERBOOKS_CLAUSE, + TAKE_ORDERS_ORDERBOOKS_CLAUSE_BODY, + orderbooks_iter(), + )?; + stmt.bind_list_clause( + CLEAR_EVENTS_ORDERBOOKS_CLAUSE, + CLEAR_EVENTS_ORDERBOOKS_CLAUSE_BODY, + orderbooks_iter(), + )?; + Ok(stmt) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::{ + hex, + primitives::{address, b256}, + }; + + #[test] + fn builds_with_chain_ids_and_tx_hash() { + let tx_hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef"); + let stmt = build_fetch_trades_by_tx_stmt(&FetchTradesByTxArgs { + chain_ids: vec![137, 1, 137], + orderbook_addresses: vec![], + tx_hash, + }) + .unwrap(); + assert_eq!(stmt.params.len(), 5); + assert_eq!( + stmt.params[0], + SqlValue::Text(hex::encode_prefixed(tx_hash)) + ); + assert_eq!(stmt.params[1], SqlValue::U64(1)); + assert_eq!(stmt.params[2], SqlValue::U64(137)); + assert_eq!(stmt.params[3], SqlValue::U64(1)); + assert_eq!(stmt.params[4], SqlValue::U64(137)); + assert!(stmt.sql.contains("t.chain_id IN (?2, ?3)")); + assert!(stmt.sql.contains("c.chain_id IN (?4, ?5)")); + assert!(!stmt.sql.contains(TAKE_ORDERS_CHAIN_IDS_CLAUSE)); + assert!(!stmt.sql.contains(CLEAR_EVENTS_CHAIN_IDS_CLAUSE)); + } + + #[test] + fn builds_with_orderbook_address_filters() { + let tx_hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef"); + let ob = address!("0x2f209e5b67a33b8fe96e28f24628df6da301c8eb"); + let stmt = build_fetch_trades_by_tx_stmt(&FetchTradesByTxArgs { + chain_ids: vec![137], + orderbook_addresses: vec![ob], + tx_hash, + }) + .unwrap(); + assert_eq!(stmt.params.len(), 5); + assert_eq!( + stmt.params[0], + SqlValue::Text(hex::encode_prefixed(tx_hash)) + ); + assert_eq!(stmt.params[1], SqlValue::U64(137)); + assert_eq!(stmt.params[2], SqlValue::U64(137)); + assert_eq!(stmt.params[3], SqlValue::Text(hex::encode_prefixed(ob))); + assert_eq!(stmt.params[4], SqlValue::Text(hex::encode_prefixed(ob))); + assert!(stmt.sql.contains("t.orderbook_address IN (?4)")); + assert!(stmt.sql.contains("c.orderbook_address IN (?5)")); + assert!(!stmt.sql.contains(TAKE_ORDERS_ORDERBOOKS_CLAUSE)); + assert!(!stmt.sql.contains(CLEAR_EVENTS_ORDERBOOKS_CLAUSE)); + } +} diff --git a/crates/common/src/local_db/query/fetch_trades_by_tx/query.sql b/crates/common/src/local_db/query/fetch_trades_by_tx/query.sql new file mode 100644 index 0000000000..97d40dfcab --- /dev/null +++ b/crates/common/src/local_db/query/fetch_trades_by_tx/query.sql @@ -0,0 +1,371 @@ +WITH +params AS ( + SELECT + ?1 AS transaction_hash +), +matching_take_orders AS ( + SELECT + 'take' AS trade_kind, + t.chain_id, + t.orderbook_address, + t.order_owner, + t.order_nonce, + t.transaction_hash, + t.log_index, + t.block_number, + t.block_timestamp, + t.sender AS transaction_sender, + t.input_io_index, + t.output_io_index, + t.taker_output AS input_delta, + FLOAT_NEGATE(t.taker_input) AS output_delta + FROM take_orders t + JOIN params p + ON t.transaction_hash = p.transaction_hash + WHERE 1 = 1 + /*TAKE_ORDERS_CHAIN_IDS_CLAUSE*/ + /*TAKE_ORDERS_ORDERBOOKS_CLAUSE*/ +), +matching_clears AS ( + SELECT + c.chain_id, + c.orderbook_address, + c.transaction_hash, + c.log_index, + c.block_number, + c.block_timestamp, + c.sender, + c.alice_order_hash, + c.bob_order_hash, + c.alice_input_io_index, + c.alice_output_io_index, + c.alice_input_vault_id, + c.alice_output_vault_id, + c.bob_input_io_index, + c.bob_output_io_index, + c.bob_input_vault_id, + c.bob_output_vault_id + FROM clear_v3_events c + JOIN params p + ON c.transaction_hash = p.transaction_hash + WHERE 1 = 1 + /*CLEAR_EVENTS_CHAIN_IDS_CLAUSE*/ + /*CLEAR_EVENTS_ORDERBOOKS_CLAUSE*/ +), +take_trades AS ( + SELECT + mt.trade_kind, + mt.chain_id, + mt.orderbook_address, + oe.order_hash, + mt.order_owner, + mt.order_nonce, + mt.transaction_hash, + mt.log_index, + mt.block_number, + mt.block_timestamp, + mt.transaction_sender, + io_in.vault_id AS input_vault_id, + io_in.token AS input_token, + mt.input_delta, + io_out.vault_id AS output_vault_id, + io_out.token AS output_token, + mt.output_delta + FROM matching_take_orders mt + JOIN order_events oe + ON oe.chain_id = mt.chain_id + AND oe.orderbook_address = mt.orderbook_address + AND oe.order_owner = mt.order_owner + AND oe.order_nonce = mt.order_nonce + AND oe.event_type = 'AddOrderV3' + AND ( + oe.block_number < mt.block_number + OR (oe.block_number = mt.block_number AND oe.log_index <= mt.log_index) + ) + AND NOT EXISTS ( + SELECT 1 + FROM order_events newer + WHERE newer.chain_id = oe.chain_id + AND newer.orderbook_address = oe.orderbook_address + AND newer.order_owner = oe.order_owner + AND newer.order_nonce = oe.order_nonce + AND newer.event_type = 'AddOrderV3' + AND ( + newer.block_number < mt.block_number + OR (newer.block_number = mt.block_number AND newer.log_index <= mt.log_index) + ) + AND ( + newer.block_number > oe.block_number + OR (newer.block_number = oe.block_number AND newer.log_index > oe.log_index) + ) + ) + JOIN order_ios io_in + ON io_in.chain_id = oe.chain_id + AND io_in.orderbook_address = oe.orderbook_address + AND io_in.transaction_hash = oe.transaction_hash + AND io_in.log_index = oe.log_index + AND io_in.io_index = mt.input_io_index + AND io_in.io_type = 'input' + JOIN order_ios io_out + ON io_out.chain_id = oe.chain_id + AND io_out.orderbook_address = oe.orderbook_address + AND io_out.transaction_hash = oe.transaction_hash + AND io_out.log_index = oe.log_index + AND io_out.io_index = mt.output_io_index + AND io_out.io_type = 'output' +), +clear_alice AS ( + SELECT DISTINCT + 'clear' AS trade_kind, + mc.chain_id, + mc.orderbook_address, + oe.order_hash, + oe.order_owner, + oe.order_nonce, + mc.transaction_hash, + mc.log_index, + mc.block_number, + mc.block_timestamp, + mc.sender AS transaction_sender, + mc.alice_input_vault_id AS input_vault_id, + io_in.token AS input_token, + a.alice_input AS input_delta, + mc.alice_output_vault_id AS output_vault_id, + io_out.token AS output_token, + FLOAT_NEGATE(a.alice_output) AS output_delta + FROM matching_clears mc + JOIN order_events oe + ON oe.chain_id = mc.chain_id + AND oe.orderbook_address = mc.orderbook_address + AND oe.order_hash = mc.alice_order_hash + AND oe.event_type = 'AddOrderV3' + AND ( + oe.block_number < mc.block_number + OR (oe.block_number = mc.block_number AND oe.log_index <= mc.log_index) + ) + AND NOT EXISTS ( + SELECT 1 + FROM order_events newer + WHERE newer.chain_id = oe.chain_id + AND newer.orderbook_address = oe.orderbook_address + AND newer.order_hash = oe.order_hash + AND newer.event_type = 'AddOrderV3' + AND ( + newer.block_number < mc.block_number + OR (newer.block_number = mc.block_number AND newer.log_index <= mc.log_index) + ) + AND ( + newer.block_number > oe.block_number + OR (newer.block_number = oe.block_number AND newer.log_index > oe.log_index) + ) + ) + JOIN after_clear_v2_events a + ON a.chain_id = mc.chain_id + AND a.orderbook_address = mc.orderbook_address + AND a.transaction_hash = mc.transaction_hash + AND a.log_index = ( + SELECT MIN(ac.log_index) + FROM after_clear_v2_events ac + WHERE ac.chain_id = mc.chain_id + AND ac.orderbook_address = mc.orderbook_address + AND ac.transaction_hash = mc.transaction_hash + AND ac.log_index > mc.log_index + ) + JOIN order_ios io_in + ON io_in.chain_id = oe.chain_id + AND io_in.orderbook_address = oe.orderbook_address + AND io_in.transaction_hash = oe.transaction_hash + AND io_in.log_index = oe.log_index + AND io_in.io_index = mc.alice_input_io_index + AND io_in.io_type = 'input' + JOIN order_ios io_out + ON io_out.chain_id = oe.chain_id + AND io_out.orderbook_address = oe.orderbook_address + AND io_out.transaction_hash = oe.transaction_hash + AND io_out.log_index = oe.log_index + AND io_out.io_index = mc.alice_output_io_index + AND io_out.io_type = 'output' +), +clear_bob AS ( + SELECT DISTINCT + 'clear' AS trade_kind, + mc.chain_id, + mc.orderbook_address, + oe.order_hash, + oe.order_owner, + oe.order_nonce, + mc.transaction_hash, + mc.log_index, + mc.block_number, + mc.block_timestamp, + mc.sender AS transaction_sender, + mc.bob_input_vault_id AS input_vault_id, + io_in.token AS input_token, + a.bob_input AS input_delta, + mc.bob_output_vault_id AS output_vault_id, + io_out.token AS output_token, + FLOAT_NEGATE(a.bob_output) AS output_delta + FROM matching_clears mc + JOIN order_events oe + ON oe.chain_id = mc.chain_id + AND oe.orderbook_address = mc.orderbook_address + AND oe.order_hash = mc.bob_order_hash + AND oe.event_type = 'AddOrderV3' + AND ( + oe.block_number < mc.block_number + OR (oe.block_number = mc.block_number AND oe.log_index <= mc.log_index) + ) + AND NOT EXISTS ( + SELECT 1 + FROM order_events newer + WHERE newer.chain_id = oe.chain_id + AND newer.orderbook_address = oe.orderbook_address + AND newer.order_hash = oe.order_hash + AND newer.event_type = 'AddOrderV3' + AND ( + newer.block_number < mc.block_number + OR (newer.block_number = mc.block_number AND newer.log_index <= mc.log_index) + ) + AND ( + newer.block_number > oe.block_number + OR (newer.block_number = oe.block_number AND newer.log_index > oe.log_index) + ) + ) + JOIN after_clear_v2_events a + ON a.chain_id = mc.chain_id + AND a.orderbook_address = mc.orderbook_address + AND a.transaction_hash = mc.transaction_hash + AND a.log_index = ( + SELECT MIN(ac.log_index) + FROM after_clear_v2_events ac + WHERE ac.chain_id = mc.chain_id + AND ac.orderbook_address = mc.orderbook_address + AND ac.transaction_hash = mc.transaction_hash + AND ac.log_index > mc.log_index + ) + JOIN order_ios io_in + ON io_in.chain_id = oe.chain_id + AND io_in.orderbook_address = oe.orderbook_address + AND io_in.transaction_hash = oe.transaction_hash + AND io_in.log_index = oe.log_index + AND io_in.io_index = mc.bob_input_io_index + AND io_in.io_type = 'input' + JOIN order_ios io_out + ON io_out.chain_id = oe.chain_id + AND io_out.orderbook_address = oe.orderbook_address + AND io_out.transaction_hash = oe.transaction_hash + AND io_out.log_index = oe.log_index + AND io_out.io_index = mc.bob_output_io_index + AND io_out.io_type = 'output' +), +clear_trades AS ( + SELECT * FROM clear_alice + UNION ALL + SELECT * FROM clear_bob +), +unioned_trades AS ( + SELECT * FROM take_trades + UNION ALL + SELECT * FROM clear_trades +), +trade_rows AS ( + SELECT + ut.trade_kind, + ut.chain_id, + ut.orderbook_address, + ut.order_hash, + ut.order_owner, + ut.order_nonce, + ut.transaction_hash, + ut.log_index, + ut.block_number, + ut.block_timestamp, + ut.transaction_sender, + ut.input_vault_id, + ut.input_token, + ut.input_delta, + ut.output_vault_id, + ut.output_token, + ut.output_delta + FROM unioned_trades ut +), +trade_with_snapshots AS ( + SELECT + tr.*, + mvb_in.balance AS input_base_balance, + mvb_in.last_block AS input_base_block, + mvb_in.last_log_index AS input_base_log_index, + mvb_out.balance AS output_base_balance, + mvb_out.last_block AS output_base_block, + mvb_out.last_log_index AS output_base_log_index + FROM trade_rows tr + LEFT JOIN running_vault_balances mvb_in + ON mvb_in.chain_id = tr.chain_id + AND mvb_in.orderbook_address = tr.orderbook_address + AND mvb_in.owner = tr.order_owner + AND mvb_in.token = tr.input_token + AND mvb_in.vault_id = tr.input_vault_id + LEFT JOIN running_vault_balances mvb_out + ON mvb_out.chain_id = tr.chain_id + AND mvb_out.orderbook_address = tr.orderbook_address + AND mvb_out.owner = tr.order_owner + AND mvb_out.token = tr.output_token + AND mvb_out.vault_id = tr.output_vault_id +) +SELECT + tws.chain_id, + tws.trade_kind, + tws.orderbook_address AS orderbook, + tws.order_hash, + tws.order_owner, + tws.order_nonce, + tws.transaction_hash, + tws.log_index, + tws.block_number, + tws.block_timestamp, + tws.transaction_sender, + tws.input_vault_id, + tws.input_token, + tok_in.name AS input_token_name, + tok_in.symbol AS input_token_symbol, + tok_in.decimals AS input_token_decimals, + tws.input_delta, + vbc_input.running_balance AS input_running_balance, + tws.output_vault_id, + tws.output_token, + tok_out.name AS output_token_name, + tok_out.symbol AS output_token_symbol, + tok_out.decimals AS output_token_decimals, + tws.output_delta, + vbc_output.running_balance AS output_running_balance, + ( + '0x' || + lower(replace(tws.transaction_hash, '0x', '')) || + printf('%016x', tws.log_index) + ) AS trade_id +FROM trade_with_snapshots tws +LEFT JOIN vault_balance_changes vbc_input + ON vbc_input.chain_id = tws.chain_id + AND vbc_input.orderbook_address = tws.orderbook_address + AND vbc_input.owner = tws.order_owner + AND vbc_input.token = tws.input_token + AND vbc_input.vault_id = tws.input_vault_id + AND vbc_input.block_number = tws.block_number + AND vbc_input.log_index = tws.log_index +LEFT JOIN vault_balance_changes vbc_output + ON vbc_output.chain_id = tws.chain_id + AND vbc_output.orderbook_address = tws.orderbook_address + AND vbc_output.owner = tws.order_owner + AND vbc_output.token = tws.output_token + AND vbc_output.vault_id = tws.output_vault_id + AND vbc_output.block_number = tws.block_number + AND vbc_output.log_index = tws.log_index +LEFT JOIN erc20_tokens tok_in + ON tok_in.chain_id = tws.chain_id + AND tok_in.orderbook_address = tws.orderbook_address + AND tok_in.token_address = tws.input_token +LEFT JOIN erc20_tokens tok_out + ON tok_out.chain_id = tws.chain_id + AND tok_out.orderbook_address = tws.orderbook_address + AND tok_out.token_address = tws.output_token +ORDER BY tws.block_timestamp DESC, tws.block_number DESC, tws.log_index DESC, tws.trade_kind; diff --git a/crates/common/src/local_db/query/mod.rs b/crates/common/src/local_db/query/mod.rs index 0d8a989e98..2a8e475c4d 100644 --- a/crates/common/src/local_db/query/mod.rs +++ b/crates/common/src/local_db/query/mod.rs @@ -16,6 +16,7 @@ pub mod fetch_orders_count; pub mod fetch_store_addresses; pub mod fetch_tables; pub mod fetch_target_watermark; +pub mod fetch_trades_by_tx; pub mod fetch_transaction_by_hash; pub mod fetch_vault_balance_changes; pub mod fetch_vaults; diff --git a/crates/common/src/raindex_client/local_db/orders.rs b/crates/common/src/raindex_client/local_db/orders.rs index 5708713d13..f38e6703cd 100644 --- a/crates/common/src/raindex_client/local_db/orders.rs +++ b/crates/common/src/raindex_client/local_db/orders.rs @@ -192,7 +192,7 @@ impl OrdersDataSource for LocalDbOrders<'_> { local_trades .into_iter() - .map(|trade| RaindexTrade::try_from_local_db_trade(ob_id.chain_id, trade)) + .map(RaindexTrade::try_from_local_db_trade) .collect() } diff --git a/crates/common/src/raindex_client/local_db/query/fetch_trades_by_tx.rs b/crates/common/src/raindex_client/local_db/query/fetch_trades_by_tx.rs new file mode 100644 index 0000000000..f060c3fb03 --- /dev/null +++ b/crates/common/src/raindex_client/local_db/query/fetch_trades_by_tx.rs @@ -0,0 +1,50 @@ +use crate::local_db::query::fetch_order_trades::LocalDbOrderTrade; +use crate::local_db::query::fetch_trades_by_tx::{ + build_fetch_trades_by_tx_stmt, FetchTradesByTxArgs, +}; +use crate::local_db::query::{LocalDbQueryError, LocalDbQueryExecutor}; + +pub async fn fetch_trades_by_tx( + exec: &E, + args: FetchTradesByTxArgs, +) -> Result, LocalDbQueryError> { + let stmt = build_fetch_trades_by_tx_stmt(&args)?; + exec.query_json(&stmt).await +} + +#[cfg(all(test, target_family = "wasm"))] +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; + use std::cell::RefCell; + use std::rc::Rc; + use wasm_bindgen_test::*; + use wasm_bindgen_utils::prelude::*; + + #[wasm_bindgen_test] + async fn wrapper_uses_builder_sql_exactly() { + let tx_hash = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef"); + let args = FetchTradesByTxArgs { + chain_ids: vec![137, 42161], + orderbook_addresses: vec![], + tx_hash, + }; + + let expected_stmt = build_fetch_trades_by_tx_stmt(&args).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 = fetch_trades_by_tx(&exec, args).await; + assert!(res.is_ok()); + + let captured = store.borrow().clone(); + assert_eq!(captured.0, expected_stmt.sql); + } +} diff --git a/crates/common/src/raindex_client/local_db/query/mod.rs b/crates/common/src/raindex_client/local_db/query/mod.rs index c57dcc15ef..0f69a05f69 100644 --- a/crates/common/src/raindex_client/local_db/query/mod.rs +++ b/crates/common/src/raindex_client/local_db/query/mod.rs @@ -10,6 +10,7 @@ pub mod fetch_orders; pub mod fetch_orders_count; pub mod fetch_store_addresses; pub mod fetch_tables; +pub mod fetch_trades_by_tx; pub mod fetch_transaction_by_hash; pub mod fetch_vault_balance_changes; pub mod fetch_vaults; diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index 5cb150ffd4..dc799fdd65 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -2989,7 +2989,8 @@ mod tests { }, "order": { "id": "0x557147dd0daa80d5beff0023fe6a3505469b2b8c4406ce1ab873e1a652572dd4", - "orderHash": "0x557147dd0daa80d5beff0023fe6a3505469b2b8c4406ce1ab873e1a652572dd4" + "orderHash": "0x557147dd0daa80d5beff0023fe6a3505469b2b8c4406ce1ab873e1a652572dd4", + "owner": "0xf08bcbce72f62c95dcb7c07dcb5ed26acfcfbc11" }, "orderbook": { "id": CHAIN_ID_1_ORDERBOOK_ADDRESS diff --git a/crates/common/src/raindex_client/trades.rs b/crates/common/src/raindex_client/trades.rs index 2f1d30ac85..5590211bd0 100644 --- a/crates/common/src/raindex_client/trades.rs +++ b/crates/common/src/raindex_client/trades.rs @@ -3,15 +3,21 @@ use super::orders::{OrdersDataSource, SubgraphOrders}; use super::ClientRef; use super::*; use crate::local_db::query::fetch_order_trades::LocalDbOrderTrade; +use crate::local_db::query::fetch_trades_by_tx::FetchTradesByTxArgs; use crate::local_db::OrderbookIdentifier; -use crate::raindex_client::QuerySource; +use crate::raindex_client::local_db::query::fetch_trades_by_tx::fetch_trades_by_tx; use crate::raindex_client::{ orders::RaindexOrder, transactions::RaindexTransaction, vaults::{LocalTradeBalanceInfo, LocalTradeTokenInfo, RaindexVaultBalanceChange}, }; use alloy::primitives::{Address, Bytes, B256, U256}; -use rain_orderbook_subgraph_client::types::{common::SgTrade, Id}; +use rain_math_float::Float; +use rain_orderbook_subgraph_client::{ + types::{common::SgTrade, Id}, + MultiOrderbookSubgraphClient, +}; +use std::ops::{Add, Div, Sub}; use std::str::FromStr; #[cfg(target_family = "wasm")] use wasm_bindgen_utils::prelude::js_sys::BigInt; @@ -21,12 +27,16 @@ use wasm_bindgen_utils::prelude::js_sys::BigInt; #[wasm_bindgen] pub struct RaindexTrade { id: Bytes, - order_hash: Bytes, + chain_id: u32, + orderbook: Address, + order_hash: B256, + owner: Address, transaction: RaindexTransaction, input_vault_balance_change: RaindexVaultBalanceChange, output_vault_balance_change: RaindexVaultBalanceChange, timestamp: U256, - orderbook: Address, + io_ratio: Float, + formatted_io_ratio: String, } #[cfg(target_family = "wasm")] #[wasm_bindgen] @@ -35,10 +45,22 @@ impl RaindexTrade { pub fn id(&self) -> String { self.id.to_string() } + #[wasm_bindgen(getter = chainId)] + pub fn chain_id(&self) -> u32 { + self.chain_id + } + #[wasm_bindgen(getter, unchecked_return_type = "Address")] + pub fn orderbook(&self) -> String { + self.orderbook.to_string() + } #[wasm_bindgen(getter = orderHash, unchecked_return_type = "Hex")] pub fn order_hash(&self) -> String { self.order_hash.to_string() } + #[wasm_bindgen(getter, unchecked_return_type = "Address")] + pub fn owner(&self) -> String { + self.owner.to_string() + } #[wasm_bindgen(getter)] pub fn transaction(&self) -> RaindexTransaction { self.transaction.clone() @@ -56,9 +78,13 @@ impl RaindexTrade { BigInt::from_str(&self.timestamp.to_string()) .map_err(|e| RaindexError::JsError(e.to_string().into())) } - #[wasm_bindgen(getter, unchecked_return_type = "Address")] - pub fn orderbook(&self) -> String { - self.orderbook.to_string() + #[wasm_bindgen(getter = ioRatio)] + pub fn io_ratio(&self) -> Float { + self.io_ratio + } + #[wasm_bindgen(getter = formattedIoRatio)] + pub fn formatted_io_ratio(&self) -> String { + self.formatted_io_ratio.clone() } } #[cfg(not(target_family = "wasm"))] @@ -66,8 +92,17 @@ impl RaindexTrade { pub fn id(&self) -> Bytes { self.id.clone() } - pub fn order_hash(&self) -> Bytes { - self.order_hash.clone() + pub fn chain_id(&self) -> u32 { + self.chain_id + } + pub fn orderbook(&self) -> Address { + self.orderbook + } + pub fn order_hash(&self) -> B256 { + self.order_hash + } + pub fn owner(&self) -> Address { + self.owner } pub fn transaction(&self) -> RaindexTransaction { self.transaction.clone() @@ -81,8 +116,149 @@ impl RaindexTrade { pub fn timestamp(&self) -> U256 { self.timestamp } - pub fn orderbook(&self) -> Address { - self.orderbook + pub fn io_ratio(&self) -> Float { + self.io_ratio + } + pub fn formatted_io_ratio(&self) -> &str { + &self.formatted_io_ratio + } +} + +#[wasm_export] +impl RaindexClient { + /// Fetches all trades from a specific transaction + /// + /// Queries either local DB or subgraph based on the configured data source + /// for each chain, using the classify_chains pattern. + /// + /// ## Examples + /// + /// ```javascript + /// const result = await client.getTradesForTransaction( + /// undefined, + /// undefined, + /// "0xabcdef..." + /// ); + /// if (result.error) { + /// console.error("Error:", result.error.readableMsg); + /// return; + /// } + /// const { trades, totalCount, summary } = result.value; + /// ``` + #[wasm_export( + js_name = "getTradesForTransaction", + return_description = "Trades list result with total count and per-pair summary", + unchecked_return_type = "RaindexTradesListResult", + preserve_js_class + )] + pub async fn get_trades_for_transaction_wasm_binding( + &self, + #[wasm_export( + js_name = "chainIds", + param_description = "Optional chain IDs to filter networks (queries all if not specified)" + )] + chain_ids: Option, + #[wasm_export( + js_name = "orderbookAddresses", + param_description = "Optional orderbook addresses to filter results" + )] + orderbook_addresses: Option>, + #[wasm_export( + js_name = "txHash", + param_description = "Transaction hash", + unchecked_param_type = "Hex" + )] + tx_hash: String, + ) -> Result { + let tx_hash = B256::from_str(&tx_hash)?; + let orderbook_addresses = orderbook_addresses + .map(|addresses| { + addresses + .into_iter() + .map(|address| Address::from_str(&address)) + .collect::, _>>() + }) + .transpose()?; + self.get_trades_for_transaction(chain_ids, orderbook_addresses, tx_hash) + .await + } +} +impl RaindexClient { + pub async fn get_trades_for_transaction( + &self, + chain_ids: Option, + orderbook_addresses: Option>, + tx_hash: B256, + ) -> Result { + let ids = chain_ids.map(|ChainIds(ids)| ids); + let (local_db, local_ids, sg_ids) = self.classify_chains(ids)?; + let orderbook_addresses_for_local_db = orderbook_addresses.clone().unwrap_or_default(); + + let mut all_trades = Vec::new(); + + if let Some(db) = local_db.filter(|_| !local_ids.is_empty()) { + let trades = fetch_trades_by_tx( + &db, + FetchTradesByTxArgs { + chain_ids: local_ids, + orderbook_addresses: orderbook_addresses_for_local_db, + tx_hash, + }, + ) + .await?; + let raindex_trades: Vec = trades + .into_iter() + .map(RaindexTrade::try_from_local_db_trade) + .collect::>()?; + all_trades.extend(raindex_trades); + } + + if !sg_ids.is_empty() { + let multi_subgraph_args = self.get_multi_subgraph_args(Some(sg_ids))?; + let orderbook_in = orderbook_addresses + .as_deref() + .filter(|addresses| !addresses.is_empty()) + .map(|addresses| { + addresses + .iter() + .map(|address| address.to_string().to_lowercase()) + .collect::>() + }); + if !multi_subgraph_args.is_empty() { + let name_to_chain_id: std::collections::HashMap<&str, u32> = multi_subgraph_args + .iter() + .flat_map(|(chain_id, args)| { + args.iter().map(|arg| (arg.name.as_str(), *chain_id)) + }) + .collect(); + let client = MultiOrderbookSubgraphClient::new( + multi_subgraph_args.values().flatten().cloned().collect(), + ); + let sg_trades = client + .trades_by_transaction(tx_hash.to_string(), orderbook_in) + .await; + for trade_with_name in sg_trades { + let chain_id = name_to_chain_id + .get(trade_with_name.subgraph_name.as_str()) + .copied() + .ok_or(RaindexError::SubgraphNotFound( + trade_with_name.subgraph_name.clone(), + trade_with_name.trade.id.0.clone(), + ))?; + let trade = RaindexTrade::try_from_sg_trade(chain_id, trade_with_name.trade)?; + all_trades.push(trade); + } + } + } + + let total_count = all_trades.len() as u64; + let summary = RaindexPairSummary::from_trades(&all_trades)?; + + Ok(RaindexTradesListResult { + trades: all_trades, + total_count, + summary: Some(summary), + }) } } @@ -91,7 +267,7 @@ impl RaindexOrder { /// Fetches trade history with optional time filtering /// /// Retrieves a chronological list of trades executed by an order within - /// an optional time range. + /// an optional time range, along with the total count and optional per-pair summary. /// /// ## Examples /// @@ -101,13 +277,13 @@ impl RaindexOrder { /// console.error("Cannot fetch trades:", result.error.readableMsg); /// return; /// } - /// const trades = result.value; - /// // Do something with the trades + /// const { trades, totalCount, summary } = result.value; /// ``` #[wasm_export( js_name = "getTradesList", - return_description = "Array of trade records with complete details", - unchecked_return_type = "RaindexTrade[]" + return_description = "Trades list result with total count and optional per-pair summary", + unchecked_return_type = "RaindexTradesListResult", + preserve_js_class )] pub async fn get_trades_list( &self, @@ -126,7 +302,7 @@ impl RaindexOrder { param_description = "Optional page number (defaults to 1)" )] page: Option, - ) -> Result, RaindexError> { + ) -> Result { let chain_id = self.chain_id(); #[cfg(target_family = "wasm")] let orderbook = Address::from_str(&self.orderbook())?; @@ -141,20 +317,48 @@ impl RaindexOrder { let ob_id = OrderbookIdentifier::new(chain_id, orderbook); let raindex_client = self.get_raindex_client(); - match raindex_client.query_source(chain_id) { + let (trades, total_count) = match raindex_client.query_source(chain_id) { QuerySource::LocalDb(local_db) => { let local_source = LocalDbOrders::new(&local_db, ClientRef::clone(&raindex_client)); - local_source + let trades = local_source .trades_list(&ob_id, &order_hash, start_timestamp, end_timestamp, page) - .await + .await?; + let total_count = if page.is_some() { + local_source + .trades_count(&ob_id, &order_hash, start_timestamp, end_timestamp) + .await? + } else { + trades.len() as u64 + }; + (trades, total_count) } QuerySource::Subgraph => { let subgraph_source = SubgraphOrders::new(&raindex_client); - subgraph_source + let trades = subgraph_source .trades_list(&ob_id, &order_hash, start_timestamp, end_timestamp, page) - .await + .await?; + let total_count = if page.is_some() { + subgraph_source + .trades_count(&ob_id, &order_hash, start_timestamp, end_timestamp) + .await? + } else { + trades.len() as u64 + }; + (trades, total_count) } - } + }; + + let summary = if page.is_some() { + None + } else { + Some(RaindexPairSummary::from_trades(&trades)?) + }; + + Ok(RaindexTradesListResult { + trades, + total_count, + summary, + }) } /// Fetches detailed information for a specific trade @@ -171,7 +375,6 @@ impl RaindexOrder { /// return; /// } /// const trade = result.value; - /// // Do something with the trade /// ``` #[wasm_export( js_name = "getTradeDetail", @@ -190,70 +393,6 @@ impl RaindexOrder { let trade_id = Bytes::from_str(&trade_id)?; self.get_trade_detail(trade_id).await } - - /// Counts total trades for an order within a time range - /// - /// Efficiently counts the total number of trades executed by an order without - /// fetching all trade details. - /// - /// ## Examples - /// - /// ```javascript - /// const result = await order.getTradeCount(); - /// if (result.error) { - /// console.error("Cannot count trades:", result.error.readableMsg); - /// return; - /// } - /// const count = result.value; - /// // Do something with the count - /// ``` - #[wasm_export( - js_name = "getTradeCount", - return_description = "Total trade count as number", - unchecked_return_type = "number" - )] - pub async fn get_trade_count( - &self, - #[wasm_export( - js_name = "startTimestamp", - param_description = "Optional start time filter (Unix timestamp in seconds)" - )] - start_timestamp: Option, - #[wasm_export( - js_name = "endTimestamp", - param_description = "Optional end time filter (Unix timestamp in seconds)" - )] - end_timestamp: Option, - ) -> Result { - let chain_id = self.chain_id(); - #[cfg(target_family = "wasm")] - let orderbook = Address::from_str(&self.orderbook())?; - #[cfg(not(target_family = "wasm"))] - let orderbook = self.orderbook(); - - #[cfg(target_family = "wasm")] - let order_hash = B256::from_str(&self.order_hash())?; - #[cfg(not(target_family = "wasm"))] - let order_hash = self.order_hash(); - - let ob_id = OrderbookIdentifier::new(chain_id, orderbook); - let raindex_client = self.get_raindex_client(); - - match raindex_client.query_source(chain_id) { - QuerySource::LocalDb(local_db) => { - let local_source = LocalDbOrders::new(&local_db, ClientRef::clone(&raindex_client)); - local_source - .trades_count(&ob_id, &order_hash, start_timestamp, end_timestamp) - .await - } - QuerySource::Subgraph => { - let subgraph_source = SubgraphOrders::new(&raindex_client); - subgraph_source - .trades_count(&ob_id, &order_hash, start_timestamp, end_timestamp) - .await - } - } - } } impl RaindexOrder { pub async fn get_trade_detail(&self, trade_id: Bytes) -> Result { @@ -268,30 +407,50 @@ impl RaindexOrder { } impl RaindexTrade { + fn compute_io_ratio( + input_amount: Float, + output_amount: Float, + ) -> Result<(Float, String), RaindexError> { + let neg_output = Float::zero()?.sub(output_amount)?; + let io_ratio = input_amount.div(neg_output)?; + let formatted_io_ratio = io_ratio.format()?; + Ok((io_ratio, formatted_io_ratio)) + } + pub fn try_from_sg_trade(chain_id: u32, trade: SgTrade) -> Result { + let input_vault_balance_change = + RaindexVaultBalanceChange::try_from_sg_trade_balance_change( + chain_id, + trade.input_vault_balance_change, + )?; + let output_vault_balance_change = + RaindexVaultBalanceChange::try_from_sg_trade_balance_change( + chain_id, + trade.output_vault_balance_change, + )?; + + let (io_ratio, formatted_io_ratio) = Self::compute_io_ratio( + input_vault_balance_change.amount(), + output_vault_balance_change.amount(), + )?; + Ok(RaindexTrade { id: Bytes::from_str(&trade.id.0)?, - order_hash: Bytes::from_str(&trade.order.order_hash.0)?, + chain_id, + orderbook: Address::from_str(&trade.orderbook.id.0)?, + order_hash: B256::from_str(&trade.order.order_hash.0)?, + owner: Address::from_str(&trade.order.owner.0)?, transaction: RaindexTransaction::try_from(trade.trade_event.transaction)?, - input_vault_balance_change: - RaindexVaultBalanceChange::try_from_sg_trade_balance_change( - chain_id, - trade.input_vault_balance_change, - )?, - output_vault_balance_change: - RaindexVaultBalanceChange::try_from_sg_trade_balance_change( - chain_id, - trade.output_vault_balance_change, - )?, + input_vault_balance_change, + output_vault_balance_change, timestamp: U256::from_str(&trade.timestamp.0)?, - orderbook: Address::from_str(&trade.orderbook.id.0)?, + io_ratio, + formatted_io_ratio, }) } - pub(crate) fn try_from_local_db_trade( - chain_id: u32, - trade: LocalDbOrderTrade, - ) -> Result { + pub(crate) fn try_from_local_db_trade(trade: LocalDbOrderTrade) -> Result { + let chain_id = trade.chain_id; let transaction = RaindexTransaction::from_local_parts( trade.transaction_hash, trade.transaction_sender, @@ -337,18 +496,231 @@ impl RaindexTrade { trade.block_timestamp, )?; + let (io_ratio, formatted_io_ratio) = + Self::compute_io_ratio(input_change.amount(), output_change.amount())?; + Ok(RaindexTrade { id: Bytes::from_str(&trade.trade_id)?, - order_hash: trade.order_hash.into(), + chain_id, + orderbook: trade.orderbook, + order_hash: trade.order_hash, + owner: trade.order_owner, transaction, input_vault_balance_change: input_change, output_vault_balance_change: output_change, timestamp: U256::from(trade.block_timestamp), - orderbook: trade.orderbook, + io_ratio, + formatted_io_ratio, }) } } +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +#[wasm_bindgen] +pub struct RaindexPairSummary { + chain_id: u32, + input_token: Address, + output_token: Address, + total_input: Float, + formatted_total_input: String, + total_output: Float, + formatted_total_output: String, + average_io_ratio: Float, + formatted_average_io_ratio: String, + trade_count: u64, +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen] +impl RaindexPairSummary { + #[wasm_bindgen(getter = chainId)] + pub fn chain_id(&self) -> u32 { + self.chain_id + } + #[wasm_bindgen(getter = inputToken, unchecked_return_type = "Address")] + pub fn input_token(&self) -> String { + self.input_token.to_string() + } + #[wasm_bindgen(getter = outputToken, unchecked_return_type = "Address")] + pub fn output_token(&self) -> String { + self.output_token.to_string() + } + #[wasm_bindgen(getter = totalInput)] + pub fn total_input(&self) -> Float { + self.total_input + } + #[wasm_bindgen(getter = formattedTotalInput)] + pub fn formatted_total_input(&self) -> String { + self.formatted_total_input.clone() + } + #[wasm_bindgen(getter = totalOutput)] + pub fn total_output(&self) -> Float { + self.total_output + } + #[wasm_bindgen(getter = formattedTotalOutput)] + pub fn formatted_total_output(&self) -> String { + self.formatted_total_output.clone() + } + #[wasm_bindgen(getter = averageIoRatio)] + pub fn average_io_ratio(&self) -> Float { + self.average_io_ratio + } + #[wasm_bindgen(getter = formattedAverageIoRatio)] + pub fn formatted_average_io_ratio(&self) -> String { + self.formatted_average_io_ratio.clone() + } + #[wasm_bindgen(getter = tradeCount)] + pub fn trade_count(&self) -> u64 { + self.trade_count + } +} + +#[cfg(not(target_family = "wasm"))] +impl RaindexPairSummary { + pub fn chain_id(&self) -> u32 { + self.chain_id + } + pub fn input_token(&self) -> Address { + self.input_token + } + pub fn output_token(&self) -> Address { + self.output_token + } + pub fn total_input(&self) -> Float { + self.total_input + } + pub fn formatted_total_input(&self) -> &str { + &self.formatted_total_input + } + pub fn total_output(&self) -> Float { + self.total_output + } + pub fn formatted_total_output(&self) -> &str { + &self.formatted_total_output + } + pub fn average_io_ratio(&self) -> Float { + self.average_io_ratio + } + pub fn formatted_average_io_ratio(&self) -> &str { + &self.formatted_average_io_ratio + } + pub fn trade_count(&self) -> u64 { + self.trade_count + } +} + +impl RaindexPairSummary { + pub fn from_trades(trades: &[RaindexTrade]) -> Result, RaindexError> { + use std::collections::HashMap; + + let mut buckets: HashMap<(u32, Address, Address), Vec<&RaindexTrade>> = HashMap::new(); + + for trade in trades { + #[cfg(target_family = "wasm")] + let input_token = + Address::from_str(&trade.input_vault_balance_change.token().address())?; + #[cfg(not(target_family = "wasm"))] + let input_token = trade.input_vault_balance_change.token().address(); + + #[cfg(target_family = "wasm")] + let output_token = + Address::from_str(&trade.output_vault_balance_change.token().address())?; + #[cfg(not(target_family = "wasm"))] + let output_token = trade.output_vault_balance_change.token().address(); + + let key = (trade.chain_id, input_token, output_token); + buckets.entry(key).or_default().push(trade); + } + + let mut summaries = Vec::with_capacity(buckets.len()); + + for ((chain_id, input_token, output_token), bucket) in buckets { + let mut total_input = Float::zero()?; + let mut total_output = Float::zero()?; + + let zero = Float::zero()?; + for trade in &bucket { + total_input = total_input.add(trade.input_vault_balance_change.amount())?; + let neg_output = zero.sub(trade.output_vault_balance_change.amount())?; + total_output = total_output.add(neg_output)?; + } + + let formatted_total_input = total_input.format()?; + let formatted_total_output = total_output.format()?; + + let average_io_ratio = if total_output.eq(Float::zero()?).unwrap_or(true) { + Float::zero()? + } else { + total_input.div(total_output)? + }; + let formatted_average_io_ratio = average_io_ratio.format()?; + + summaries.push(RaindexPairSummary { + chain_id, + input_token, + output_token, + total_input, + formatted_total_input, + total_output, + formatted_total_output, + average_io_ratio, + formatted_average_io_ratio, + trade_count: bucket.len() as u64, + }); + } + + summaries.sort_by(|a, b| { + (a.chain_id, a.input_token, a.output_token).cmp(&( + b.chain_id, + b.input_token, + b.output_token, + )) + }); + + Ok(summaries) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +#[wasm_bindgen] +pub struct RaindexTradesListResult { + trades: Vec, + total_count: u64, + summary: Option>, +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen] +impl RaindexTradesListResult { + #[wasm_bindgen(getter, unchecked_return_type = "RaindexTrade[]")] + pub fn trades(&self) -> Vec { + self.trades.clone() + } + #[wasm_bindgen(getter = totalCount)] + pub fn total_count(&self) -> u64 { + self.total_count + } + #[wasm_bindgen(getter, unchecked_return_type = "RaindexPairSummary[] | undefined")] + pub fn summary(&self) -> Option> { + self.summary.clone() + } +} + +#[cfg(not(target_family = "wasm"))] +impl RaindexTradesListResult { + pub fn trades(&self) -> &[RaindexTrade] { + &self.trades + } + pub fn total_count(&self) -> u64 { + self.total_count + } + pub fn summary(&self) -> Option<&[RaindexPairSummary]> { + self.summary.as_deref() + } +} + #[cfg(test)] mod test_helpers { #[cfg(target_family = "wasm")] @@ -481,6 +853,7 @@ mod test_helpers { }; let trade = LocalDbOrderTrade { + chain_id: CHAIN_ID, trade_kind: "take".into(), orderbook: orderbook_address, order_hash: order_hash.clone(), @@ -651,8 +1024,10 @@ mod test_helpers { .await .unwrap(); - let trades = order.get_trades_list(None, None, None).await.unwrap(); + let result = order.get_trades_list(None, None, None).await.unwrap(); + assert_eq!(result.total_count(), 1); + let trades = result.trades(); assert_eq!(trades.len(), 1); let trade = trades.first().unwrap(); @@ -721,42 +1096,6 @@ mod test_helpers { fixture.output_token.to_string().to_lowercase() ); } - - #[wasm_bindgen_test] - async fn test_get_trade_count_local_db_path() { - let fixture = build_local_trade_fixture( - b256!("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), - 3, - 7, - ); - - let callback = make_local_db_trades_callback( - vec![fixture.order.clone()], - vec![fixture.input_vault.clone(), fixture.output_vault.clone()], - vec![fixture.trade.clone()], - 7, - ); - let client = new_test_client_with_db_callback( - vec![get_local_db_test_yaml()], - callback, - vec![42161], - ); - - let order = client - .get_order_by_hash( - &OrderbookIdentifier::new(42161, fixture.orderbook_address), - fixture.order_hash.clone(), - ) - .await - .unwrap(); - - let count = order - .get_trade_count(Some(1_699_999_900), Some(1_700_000_900)) - .await - .unwrap(); - - assert_eq!(count, 7); - } } #[cfg(not(target_family = "wasm"))] @@ -938,7 +1277,8 @@ mod test_helpers { }, "order": { "id": "0x0123", - "orderHash": "0x0123" + "orderHash": "0x0000000000000000000000000000000000000000000000000000000000000123", + "owner": "0x0000000000000000000000000000000000000000" }, "inputVaultBalanceChange": { "id": "0x0123", @@ -1028,7 +1368,8 @@ mod test_helpers { }, "order": { "id": "0x0234", - "orderHash": "0x0234" + "orderHash": "0x0000000000000000000000000000000000000000000000000000000000000234", + "owner": "0x0000000000000000000000000000000000000001" }, "inputVaultBalanceChange": { "id": "0x0234", @@ -1121,7 +1462,9 @@ mod test_helpers { ) .await .unwrap(); - let trades = order.get_trades_list(None, None, None).await.unwrap(); + let result = order.get_trades_list(None, None, None).await.unwrap(); + assert_eq!(result.total_count(), 2); + let trades = result.trades(); assert_eq!(trades.len(), 2); let tx_hash = @@ -1261,7 +1604,13 @@ mod test_helpers { trade1.orderbook(), Address::from_str("0x1234567890abcdef1234567890abcdef12345678").unwrap() ); - assert_eq!(trade1.order_hash(), Bytes::from_str("0x0123").unwrap()); + assert_eq!( + trade1.order_hash(), + B256::from_str( + "0x0000000000000000000000000000000000000000000000000000000000000123" + ) + .unwrap() + ); let trade2 = trades[1].clone(); assert_eq!(trade2.id(), Bytes::from_str("0x0234").unwrap()); @@ -1439,63 +1788,13 @@ mod test_helpers { trade.orderbook(), Address::from_str("0x1234567890abcdef1234567890abcdef12345678").unwrap() ); - assert_eq!(trade.order_hash(), Bytes::from_str("0x0123").unwrap()); - } - - #[tokio::test] - async fn test_get_order_trades_count() { - let sg_server = MockServer::start_async().await; - sg_server.mock(|when, then| { - when.path("/sg") - .body_contains("\"first\":200") - .body_contains("\"skip\":0"); - then.status(200).json_body_obj(&json!({ - "data": { - "trades": get_trades_json() - } - })); - }); - sg_server.mock(|when, then| { - when.path("/sg") - .body_contains("\"first\":200") - .body_contains("\"skip\":200"); - then.status(200).json_body_obj(&json!({ - "data": { "trades": [] } - })); - }); - sg_server.mock(|when, then| { - when.path("/sg").body_contains("SgOrderDetailByHashQuery"); - then.status(200).json_body_obj(&json!({ - "data": { - "orders": [get_order1_json()] - } - })); - }); - - let raindex_client = RaindexClient::new( - vec![get_test_yaml( - &sg_server.url("/sg"), - "http://localhost:3000", - "http://localhost:3000", - "http://localhost:3000", - )], - None, - None, - ) - .await - .unwrap(); - let order = raindex_client - .get_order_by_hash( - &OrderbookIdentifier::new( - 1, - Address::from_str(CHAIN_ID_1_ORDERBOOK_ADDRESS).unwrap(), - ), - b256!("0x0000000000000000000000000000000000000000000000000000000000000123"), + assert_eq!( + trade.order_hash(), + B256::from_str( + "0x0000000000000000000000000000000000000000000000000000000000000123" ) - .await - .unwrap(); - let count = order.get_trade_count(None, None).await.unwrap(); - assert_eq!(count, 2); + .unwrap() + ); } } } diff --git a/crates/common/src/types/order_takes_list_flattened.rs b/crates/common/src/types/order_takes_list_flattened.rs index 411febea20..6d1da1f6ff 100644 --- a/crates/common/src/types/order_takes_list_flattened.rs +++ b/crates/common/src/types/order_takes_list_flattened.rs @@ -82,6 +82,7 @@ mod tests { order: SgTradeStructPartialOrder { id: SgBytes("orderPartial001".to_string()), order_hash: SgBytes("orderHash001".to_string()), + owner: SgBytes("0xowner001".to_string()), }, input_vault_balance_change: SgTradeVaultBalanceChange { id: SgBytes("inputVBC001".to_string()), diff --git a/crates/subgraph/src/multi_orderbook_client.rs b/crates/subgraph/src/multi_orderbook_client.rs index c51afdacc0..adf3c2f6ef 100644 --- a/crates/subgraph/src/multi_orderbook_client.rs +++ b/crates/subgraph/src/multi_orderbook_client.rs @@ -1,7 +1,7 @@ use crate::{ types::common::{ SgErc20WithSubgraphName, SgOrderWithSubgraphName, SgOrdersListFilterArgs, - SgVaultWithSubgraphName, SgVaultsListFilterArgs, + SgTradeWithSubgraphName, SgVaultWithSubgraphName, SgVaultsListFilterArgs, }, OrderbookSubgraphClient, OrderbookSubgraphClientError, SgPaginationArgs, }; @@ -141,6 +141,40 @@ impl MultiOrderbookSubgraphClient { Ok(all_vaults) } + pub async fn trades_by_transaction( + &self, + tx_id: String, + orderbook_in: Option>, + ) -> Vec { + let futures = self.subgraphs.iter().map(|subgraph| { + let url = subgraph.url.clone(); + let tx_id = tx_id.clone(); + let orderbook_in = orderbook_in.clone(); + async move { + let client = self.get_orderbook_subgraph_client(url); + let trades = client.trades_by_transaction(tx_id, orderbook_in).await?; + let wrapped_trades: Vec = trades + .into_iter() + .map(|trade| SgTradeWithSubgraphName { + trade, + subgraph_name: subgraph.name.clone(), + }) + .collect(); + Ok::<_, OrderbookSubgraphClientError>(wrapped_trades) + } + }); + + let results = join_all(futures).await; + + let all_trades: Vec = results + .into_iter() + .filter_map(Result::ok) + .flatten() + .collect(); + + all_trades + } + pub async fn tokens_list( &self, ) -> Result, OrderbookSubgraphClientError> { @@ -185,7 +219,9 @@ mod tests { use super::*; use crate::cynic_client::CynicClientError; use crate::types::common::{ - SgBigInt, SgBytes, SgErc20, SgOrder, SgOrderbook, SgOrdersListFilterArgs, SgVault, + SgBigInt, SgBytes, SgErc20, SgOrder, SgOrderbook, SgOrdersListFilterArgs, SgTrade, + SgTradeEvent, SgTradeEventTypename, SgTradeRef, SgTradeStructPartialOrder, + SgTradeVaultBalanceChange, SgTransaction, SgVault, SgVaultBalanceChangeVault, }; use crate::utils::float::*; use httpmock::prelude::*; @@ -757,6 +793,338 @@ mod tests { assert_eq!(count, ALL_PAGES_QUERY_PAGE_SIZE as u32 + 10); } + fn default_sg_transaction() -> SgTransaction { + SgTransaction { + id: SgBytes("0xtransaction_id_default".to_string()), + from: SgBytes("0xfrom_address_default".to_string()), + block_number: SgBigInt("100".to_string()), + timestamp: SgBigInt("1600000000".to_string()), + } + } + + fn default_sg_trade_erc20() -> SgErc20 { + SgErc20 { + id: SgBytes("0xtoken_id_default".to_string()), + address: SgBytes("0xtoken_address_default".to_string()), + name: Some("Default Token".to_string()), + symbol: Some("DTK".to_string()), + decimals: Some(SgBigInt("18".to_string())), + } + } + + fn default_sg_vault_balance_change_vault() -> SgVaultBalanceChangeVault { + SgVaultBalanceChangeVault { + id: SgBytes("0xvault_id_default".to_string()), + vault_id: SgBytes("12345".to_string()), + token: default_sg_trade_erc20(), + } + } + + fn default_sg_trade_event_typename() -> SgTradeEventTypename { + SgTradeEventTypename { + __typename: "TakeOrder".to_string(), + } + } + + fn default_sg_trade_ref() -> SgTradeRef { + SgTradeRef { + trade_event: default_sg_trade_event_typename(), + } + } + + fn default_sg_trade_vault_balance_change(type_name: &str) -> SgTradeVaultBalanceChange { + SgTradeVaultBalanceChange { + id: SgBytes(format!("0xtrade_vbc_{}_id_default", type_name)), + __typename: "TradeVaultBalanceChange".to_string(), + amount: SgBytes(F1.as_hex()), + new_vault_balance: SgBytes(F5.as_hex()), + old_vault_balance: SgBytes(F4.as_hex()), + vault: default_sg_vault_balance_change_vault(), + timestamp: SgBigInt("1600000100".to_string()), + transaction: default_sg_transaction(), + orderbook: SgOrderbook { + id: SgBytes("0xorderbook_id_default".to_string()), + }, + trade: default_sg_trade_ref(), + } + } + + fn default_sg_trade_event() -> SgTradeEvent { + SgTradeEvent { + transaction: default_sg_transaction(), + sender: SgBytes("0xsender_address_default".to_string()), + } + } + + fn default_sg_trade_struct_partial_order() -> SgTradeStructPartialOrder { + SgTradeStructPartialOrder { + id: SgBytes("0xorder_id_for_trade_default".to_string()), + order_hash: SgBytes("0xorder_hash_for_trade_default".to_string()), + owner: SgBytes("0xowner_address_default".to_string()), + } + } + + fn default_sg_trade() -> SgTrade { + SgTrade { + id: SgBytes("0xtrade_id_default".to_string()), + trade_event: default_sg_trade_event(), + output_vault_balance_change: default_sg_trade_vault_balance_change("output"), + order: default_sg_trade_struct_partial_order(), + input_vault_balance_change: default_sg_trade_vault_balance_change("input"), + timestamp: SgBigInt("1600000200".to_string()), + orderbook: SgOrderbook { + id: SgBytes("0xorderbook_id_default".to_string()), + }, + } + } + + #[tokio::test] + async fn test_trades_by_transaction_no_subgraphs() { + let client = MultiOrderbookSubgraphClient::new(vec![]); + let result = client + .trades_by_transaction("0xtx123".to_string(), None) + .await; + assert!(result.is_empty()); + } + + #[tokio::test] + async fn test_trades_by_transaction_one_subgraph_returns_trades() { + use crate::orderbook_client::ALL_PAGES_QUERY_PAGE_SIZE; + + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + let sg1_name = "subgraph_alpha"; + let tx_id = "0xtx_abc"; + + let trade1 = default_sg_trade(); + server1.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains("\"skip\":0"); + then.status(200) + .json_body(json!({"data": {"trades": [trade1]}})); + }); + server1.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE)); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![MultiSubgraphArgs { + url: sg1_url, + name: sg1_name.to_string(), + }]); + + let trades = client + .trades_by_transaction(tx_id.to_string(), None) + .await; + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].trade.id, trade1.id); + assert_eq!(trades[0].subgraph_name, sg1_name); + } + + #[tokio::test] + async fn test_trades_by_transaction_with_orderbook_filter() { + use crate::orderbook_client::ALL_PAGES_QUERY_PAGE_SIZE; + + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + let sg1_name = "subgraph_ob_filter"; + let tx_id = "0xtx_ob_filter"; + let orderbook_addr = "0x1234567890abcdef1234567890abcdef12345678"; + + let trade1 = default_sg_trade(); + server1.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains(orderbook_addr) + .body_contains("\"skip\":0"); + then.status(200) + .json_body(json!({"data": {"trades": [trade1]}})); + }); + server1.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains(orderbook_addr) + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE)); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![MultiSubgraphArgs { + url: sg1_url, + name: sg1_name.to_string(), + }]); + + let trades = client + .trades_by_transaction(tx_id.to_string(), Some(vec![orderbook_addr.to_string()])) + .await; + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].trade.id, trade1.id); + assert_eq!(trades[0].subgraph_name, sg1_name); + } + + #[tokio::test] + async fn test_trades_by_transaction_multiple_subgraphs_merge() { + use crate::orderbook_client::ALL_PAGES_QUERY_PAGE_SIZE; + + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + let sg1_name = "sg_one"; + + let server2 = MockServer::start_async().await; + let sg2_url = Url::parse(&server2.url("")).unwrap(); + let sg2_name = "sg_two"; + let tx_id = "0xtx_multi"; + + let trade_s1 = default_sg_trade(); + let trade_s2 = default_sg_trade(); + + server1.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains("\"skip\":0"); + then.status(200) + .json_body(json!({"data": {"trades": [trade_s1]}})); + }); + server1.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE)); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + server2.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains("\"skip\":0"); + then.status(200) + .json_body(json!({"data": {"trades": [trade_s2]}})); + }); + server2.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE)); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![ + MultiSubgraphArgs { + url: sg1_url, + name: sg1_name.to_string(), + }, + MultiSubgraphArgs { + url: sg2_url, + name: sg2_name.to_string(), + }, + ]); + + let trades = client + .trades_by_transaction(tx_id.to_string(), None) + .await; + assert_eq!(trades.len(), 2); + + let names: std::collections::HashSet<_> = + trades.iter().map(|t| t.subgraph_name.clone()).collect(); + assert!(names.contains(sg1_name)); + assert!(names.contains(sg2_name)); + } + + #[tokio::test] + async fn test_trades_by_transaction_one_subgraph_errors_others_succeed() { + use crate::orderbook_client::ALL_PAGES_QUERY_PAGE_SIZE; + + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + let sg1_name = "sg_one_ok"; + + let server2 = MockServer::start_async().await; + let sg2_url = Url::parse(&server2.url("")).unwrap(); + let sg2_name = "sg_two_error"; + let tx_id = "0xtx_partial"; + + let trade_s1 = default_sg_trade(); + server1.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains("\"skip\":0"); + then.status(200) + .json_body(json!({"data": {"trades": [trade_s1]}})); + }); + server1.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(tx_id) + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE)); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + server2.mock(|when, then| { + when.method(POST).path("/").body_contains(tx_id); + then.status(500); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![ + MultiSubgraphArgs { + url: sg1_url, + name: sg1_name.to_string(), + }, + MultiSubgraphArgs { + url: sg2_url, + name: sg2_name.to_string(), + }, + ]); + let trades = client + .trades_by_transaction(tx_id.to_string(), None) + .await; + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].trade.id, trade_s1.id); + assert_eq!(trades[0].subgraph_name, sg1_name); + } + + #[tokio::test] + async fn test_trades_by_transaction_all_subgraphs_error() { + let server1 = MockServer::start_async().await; + let sg1_url = Url::parse(&server1.url("")).unwrap(); + let sg1_name = "sg_one_err"; + + let server2 = MockServer::start_async().await; + let sg2_url = Url::parse(&server2.url("")).unwrap(); + let sg2_name = "sg_two_err"; + let tx_id = "0xtx_all_err"; + + server1.mock(|when, then| { + when.method(POST).path("/").body_contains(tx_id); + then.status(500); + }); + server2.mock(|when, then| { + when.method(POST).path("/").body_contains(tx_id); + then.status(500); + }); + + let client = MultiOrderbookSubgraphClient::new(vec![ + MultiSubgraphArgs { + url: sg1_url, + name: sg1_name.to_string(), + }, + MultiSubgraphArgs { + url: sg2_url, + name: sg2_name.to_string(), + }, + ]); + let trades = client + .trades_by_transaction(tx_id.to_string(), None) + .await; + assert!(trades.is_empty()); + } + fn sample_sg_erc20(id_suffix: &str) -> SgErc20 { SgErc20 { id: SgBytes(format!("0xtoken_id_{}", id_suffix)), diff --git a/crates/subgraph/src/orderbook_client/mod.rs b/crates/subgraph/src/orderbook_client/mod.rs index c26a72993c..fd3d2db0a9 100644 --- a/crates/subgraph/src/orderbook_client/mod.rs +++ b/crates/subgraph/src/orderbook_client/mod.rs @@ -6,7 +6,9 @@ use crate::types::order::{ SgBatchOrderDetailQuery, SgBatchOrderDetailQueryVariables, SgOrderDetailByHashQuery, SgOrderDetailByHashQueryVariables, SgOrderDetailByIdQuery, SgOrderIdList, SgOrdersListQuery, }; -use crate::types::order_trade::{SgOrderTradeDetailQuery, SgOrderTradesListQuery}; +use crate::types::order_trade::{ + SgOrderTradeDetailQuery, SgOrderTradesListQuery, SgTransactionTradesQuery, +}; use crate::types::remove_order::{ SgTransactionRemoveOrdersQuery, TransactionRemoveOrdersVariables, }; diff --git a/crates/subgraph/src/orderbook_client/order_trade.rs b/crates/subgraph/src/orderbook_client/order_trade.rs index 41e4f6e5bb..33cbed6bc0 100644 --- a/crates/subgraph/src/orderbook_client/order_trade.rs +++ b/crates/subgraph/src/orderbook_client/order_trade.rs @@ -44,6 +44,40 @@ impl OrderbookSubgraphClient { Ok(data.trades) } + pub async fn trades_by_transaction( + &self, + tx_id: String, + orderbook_in: Option>, + ) -> Result, OrderbookSubgraphClientError> { + let mut all_trades = vec![]; + let mut page = 1; + + loop { + let pagination_variables = Self::parse_pagination_args(SgPaginationArgs { + page, + page_size: ALL_PAGES_QUERY_PAGE_SIZE, + }); + let data = self + .query::( + SgPaginationWithTxIdQueryVariables { + tx_id: tx_id.clone(), + first: pagination_variables.first, + skip: pagination_variables.skip, + orderbook_in: orderbook_in.clone(), + }, + ) + .await?; + + let page_len = data.trades.len(); + all_trades.extend(data.trades); + if page_len < ALL_PAGES_QUERY_PAGE_SIZE as usize { + break; + } + page += 1; + } + Ok(all_trades) + } + /// Fetch all pages of order_takes_list query pub async fn order_trades_list_all( &self, @@ -66,10 +100,11 @@ impl OrderbookSubgraphClient { end_timestamp, ) .await?; - if page_data.is_empty() { + let page_len = page_data.len(); + all_pages_merged.extend(page_data); + if page_len < ALL_PAGES_QUERY_PAGE_SIZE as usize { break; } - all_pages_merged.extend(page_data); page += 1 } Ok(all_pages_merged) @@ -166,6 +201,7 @@ mod tests { SgTradeStructPartialOrder { id: SgBytes("0xorder_id_for_trade_default".to_string()), order_hash: SgBytes("0xorder_hash_for_trade_default".to_string()), + owner: SgBytes("0xowner_address_default".to_string()), } } @@ -472,6 +508,130 @@ mod tests { assert!(result.unwrap().is_empty()); } + #[tokio::test] + async fn test_trades_by_transaction_found() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + let tx_id = "0xtx_abc123".to_string(); + let expected_trades = vec![default_sg_trade(), default_sg_trade()]; + + sg_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .json_body(json!({"data": {"trades": expected_trades}})); + }); + sg_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + + let result = client.trades_by_transaction(tx_id, None).await; + assert!(result.is_ok()); + let trades = result.unwrap(); + assert_eq!(trades.len(), expected_trades.len()); + for (actual, expected) in trades.iter().zip(expected_trades.iter()) { + assert_sg_trade_eq(actual, expected); + } + } + + #[tokio::test] + async fn test_trades_by_transaction_empty() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + let tx_id = "0xtx_empty".to_string(); + + sg_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + + let result = client.trades_by_transaction(tx_id, None).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_trades_by_transaction_with_orderbook_filter() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + let tx_id = "0xtx_orderbook_filtered".to_string(); + let orderbook = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string(); + + sg_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("\"orderbookIn\":[\"{}\"]", orderbook)); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + + let result = client + .trades_by_transaction(tx_id, Some(vec![orderbook])) + .await; + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_trades_by_transaction_multiple_pages() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + let tx_id = "0xtx_multi_page".to_string(); + let trades_page1: Vec = (0..ALL_PAGES_QUERY_PAGE_SIZE) + .map(|_| default_sg_trade()) + .collect(); + let trades_page2: Vec = (0..50).map(|_| default_sg_trade()).collect(); + + sg_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("\"first\":{}", ALL_PAGES_QUERY_PAGE_SIZE)) + .body_contains("\"skip\":0"); + then.status(200) + .json_body(json!({"data": {"trades": trades_page1}})); + }); + sg_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("\"first\":{}", ALL_PAGES_QUERY_PAGE_SIZE)) + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE)); + then.status(200) + .json_body(json!({"data": {"trades": trades_page2}})); + }); + sg_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("\"first\":{}", ALL_PAGES_QUERY_PAGE_SIZE)) + .body_contains(format!("\"skip\":{}", ALL_PAGES_QUERY_PAGE_SIZE * 2)); + then.status(200).json_body(json!({"data": {"trades": []}})); + }); + + let result = client.trades_by_transaction(tx_id, None).await; + assert!(result.is_ok()); + let trades = result.unwrap(); + assert_eq!( + trades.len(), + ALL_PAGES_QUERY_PAGE_SIZE as usize + trades_page2.len() + ); + } + + #[tokio::test] + async fn test_trades_by_transaction_network_error() { + let sg_server = MockServer::start_async().await; + let client = setup_client(&sg_server); + let tx_id = "0xtx_error".to_string(); + + sg_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(500); + }); + + let result = client.trades_by_transaction(tx_id, None).await; + assert!(matches!( + result, + Err(OrderbookSubgraphClientError::CynicClientError(_)) + )); + } + #[tokio::test] async fn test_order_trades_list_all_network_error_on_page() { let sg_server = MockServer::start_async().await; diff --git a/crates/subgraph/src/orderbook_client/performance.rs b/crates/subgraph/src/orderbook_client/performance.rs index 45b185427b..904a4a554c 100644 --- a/crates/subgraph/src/orderbook_client/performance.rs +++ b/crates/subgraph/src/orderbook_client/performance.rs @@ -74,6 +74,7 @@ mod tests { order: SgTradeStructPartialOrder { id: SgBytes(order_id_str.to_string()), order_hash: SgBytes(format!("0xhash_{}", order_id_str)), + owner: SgBytes("0xowner_default".to_string()), }, orderbook: SgOrderbook { id: SgBytes("0xorderbook_default".to_string()), diff --git a/crates/subgraph/src/performance/apy.rs b/crates/subgraph/src/performance/apy.rs index a30a3a35f6..3f4bb1ebbc 100644 --- a/crates/subgraph/src/performance/apy.rs +++ b/crates/subgraph/src/performance/apy.rs @@ -347,6 +347,7 @@ mod tests { order: SgTradeStructPartialOrder { id: bytes.clone(), order_hash: bytes.clone(), + owner: bytes.clone(), }, trade_event: SgTradeEvent { sender: bytes.clone(), @@ -406,6 +407,7 @@ mod tests { order: SgTradeStructPartialOrder { id: bytes.clone(), order_hash: bytes.clone(), + owner: bytes.clone(), }, trade_event: SgTradeEvent { sender: bytes.clone(), diff --git a/crates/subgraph/src/performance/order_performance.rs b/crates/subgraph/src/performance/order_performance.rs index 140c031cd6..f9abeb933f 100644 --- a/crates/subgraph/src/performance/order_performance.rs +++ b/crates/subgraph/src/performance/order_performance.rs @@ -849,6 +849,7 @@ mod test { order: SgTradeStructPartialOrder { id: bytes.clone(), order_hash: bytes.clone(), + owner: bytes.clone(), }, trade_event: SgTradeEvent { sender: bytes.clone(), @@ -908,6 +909,7 @@ mod test { order: SgTradeStructPartialOrder { id: bytes.clone(), order_hash: bytes.clone(), + owner: bytes.clone(), }, trade_event: SgTradeEvent { sender: bytes.clone(), diff --git a/crates/subgraph/src/performance/vol.rs b/crates/subgraph/src/performance/vol.rs index 30b0b92602..99ed62c0bc 100644 --- a/crates/subgraph/src/performance/vol.rs +++ b/crates/subgraph/src/performance/vol.rs @@ -232,6 +232,7 @@ mod tests { order: SgTradeStructPartialOrder { id: bytes.clone(), order_hash: bytes.clone(), + owner: bytes.clone(), }, trade_event: SgTradeEvent { sender: bytes.clone(), @@ -301,6 +302,7 @@ mod tests { order: SgTradeStructPartialOrder { id: bytes.clone(), order_hash: bytes.clone(), + owner: bytes.clone(), }, trade_event: SgTradeEvent { sender: bytes.clone(), diff --git a/crates/subgraph/src/types/common.rs b/crates/subgraph/src/types/common.rs index d3d6e72e1b..faa4c44298 100644 --- a/crates/subgraph/src/types/common.rs +++ b/crates/subgraph/src/types/common.rs @@ -102,6 +102,17 @@ pub struct SgPaginationWithTimestampQueryVariables { pub timestamp_lte: Option, } +#[derive(cynic::QueryVariables, Debug, Clone, Tsify)] +pub struct SgPaginationWithTxIdQueryVariables { + #[cfg_attr(target_family = "wasm", tsify(optional))] + pub first: Option, + #[cfg_attr(target_family = "wasm", tsify(optional))] + pub skip: Option, + pub tx_id: String, + #[cfg_attr(target_family = "wasm", tsify(optional))] + pub orderbook_in: Option>, +} + #[derive(cynic::QueryFragment, Debug, Serialize, Clone, Tsify)] #[cynic(graphql_type = "Orderbook")] pub struct SgOrderbook { @@ -140,12 +151,20 @@ pub struct SgOrderWithSubgraphName { } impl_wasm_traits!(SgOrderWithSubgraphName); +#[derive(Debug, Serialize, Deserialize, Clone, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct SgTradeWithSubgraphName { + pub trade: SgTrade, + pub subgraph_name: String, +} + #[derive(cynic::QueryFragment, Debug, Serialize, Clone, Tsify)] #[cynic(graphql_type = "Order")] #[serde(rename_all = "camelCase")] pub struct SgTradeStructPartialOrder { pub id: SgBytes, pub order_hash: SgBytes, + pub owner: SgBytes, } #[derive(cynic::QueryFragment, Debug, Serialize, Clone, Tsify)] diff --git a/crates/subgraph/src/types/impls.rs b/crates/subgraph/src/types/impls.rs index 5013ce50c9..b80eafa1bb 100644 --- a/crates/subgraph/src/types/impls.rs +++ b/crates/subgraph/src/types/impls.rs @@ -239,6 +239,7 @@ mod tests { order: SgTradeStructPartialOrder { id: SgBytes("".to_string()), order_hash: SgBytes("".to_string()), + owner: SgBytes("".to_string()), }, timestamp: SgBigInt("".to_string()), orderbook: SgOrderbook { diff --git a/crates/subgraph/src/types/order_trade.rs b/crates/subgraph/src/types/order_trade.rs index bb29d45e97..844a0e41dd 100644 --- a/crates/subgraph/src/types/order_trade.rs +++ b/crates/subgraph/src/types/order_trade.rs @@ -33,3 +33,23 @@ pub struct SgOrderTradeDetailQuery { #[cfg_attr(target_family = "wasm", tsify(optional))] pub trade: Option, } + +#[derive(cynic::QueryFragment, Debug, Clone, Serialize)] +#[cynic( + graphql_type = "Query", + variables = "SgPaginationWithTxIdQueryVariables" +)] +#[cfg_attr(target_family = "wasm", derive(Tsify))] +pub struct SgTransactionTradesQuery { + #[arguments( + skip: $skip, + first: $first, + orderBy: "timestamp", + orderDirection: "desc", + where: { + tradeEvent_: { transaction: $tx_id }, + orderbook_in: $orderbook_in + } + )] + pub trades: Vec, +} diff --git a/crates/subgraph/tests/snapshots/order_trade_test__vaults_query_gql_output.snap b/crates/subgraph/tests/snapshots/order_trade_test__vaults_query_gql_output.snap index b3d715895f..58daa47d6b 100644 --- a/crates/subgraph/tests/snapshots/order_trade_test__vaults_query_gql_output.snap +++ b/crates/subgraph/tests/snapshots/order_trade_test__vaults_query_gql_output.snap @@ -51,6 +51,7 @@ query SgOrderTradeDetailQuery($id: ID!) { order { id orderHash + owner } inputVaultBalanceChange { id diff --git a/crates/subgraph/tests/snapshots/order_trades_test__vaults_query_gql_output.snap b/crates/subgraph/tests/snapshots/order_trades_test__vaults_query_gql_output.snap index c2c056004d..32f43da126 100644 --- a/crates/subgraph/tests/snapshots/order_trades_test__vaults_query_gql_output.snap +++ b/crates/subgraph/tests/snapshots/order_trades_test__vaults_query_gql_output.snap @@ -51,6 +51,7 @@ query SgOrderTradesListQuery($first: Int, $id: Bytes!, $skip: Int, $timestampGte order { id orderHash + owner } inputVaultBalanceChange { id diff --git a/flake.nix b/flake.nix index d40d887503..323cda21b7 100644 --- a/flake.nix +++ b/flake.nix @@ -148,6 +148,7 @@ set -euxo pipefail cd packages/orderbook npm install --no-check + npm run format-check npm run build npm test ''; diff --git a/package.json b/package.json index 0d0f07bcc9..4f0d7d4091 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,9 @@ "test": "npm run test --workspaces", "lint:all": "npm run lint --workspaces --if-present", "format:all": "npm run format --workspaces --if-present", + "format-check:all": "npm run format-check --workspaces --if-present", "check:all": "npm run check --workspaces --if-present", - "lint-format-check:all": "npm run lint:all && npm run format:all && npm run check:all" + "lint-format-check:all": "npm run lint:all && npm run format-check:all && npm run check:all" }, "devDependencies": { "@square/svelte-store": "1.0.18", diff --git a/packages/orderbook/package.json b/packages/orderbook/package.json index d5dd1736d5..2b27874964 100644 --- a/packages/orderbook/package.json +++ b/packages/orderbook/package.json @@ -37,6 +37,7 @@ "rm-temp": "rimraf ./temp", "test": "npm run check && vitest run --dir test", "format": "prettier --write test", + "format-check": "prettier --list-different test", "check": "tsc ./dist/**/*.{ts,js} --noEmit --allowJs --lib es2020,dom", "docs": "typedoc", "docs:clean": "rimraf ./docs" diff --git a/packages/orderbook/test/js_api/raindexClient.test.ts b/packages/orderbook/test/js_api/raindexClient.test.ts index d777a9461d..e7dba6a238 100644 --- a/packages/orderbook/test/js_api/raindexClient.test.ts +++ b/packages/orderbook/test/js_api/raindexClient.test.ts @@ -426,7 +426,8 @@ describe('Rain Orderbook JS API Package Bindgen Tests - Raindex Client', async f }, order: { id: BYTES32_VOL_ORDER, - orderHash: BYTES32_VOL_ORDER + orderHash: BYTES32_VOL_ORDER, + owner: '0x0000000000000000000000000000000000000000' }, inputVaultBalanceChange: { amount: float50, @@ -503,7 +504,8 @@ describe('Rain Orderbook JS API Package Bindgen Tests - Raindex Client', async f }, order: { id: order1.id, - orderHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + orderHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + owner: '0x0000000000000000000000000000000000000000' }, inputVaultBalanceChange: { amount: '0x0000000000000000000000000000000000000000000000000000000000000003', @@ -542,7 +544,8 @@ describe('Rain Orderbook JS API Package Bindgen Tests - Raindex Client', async f id: BYTES32_0123, order: { id: BYTES32_0123, - orderHash: BYTES32_0123 + orderHash: BYTES32_0123, + owner: '0x0000000000000000000000000000000000000000' }, tradeEvent: { sender: '0x0000000000000000000000000000000000000000', @@ -1028,78 +1031,86 @@ describe('Rain Orderbook JS API Package Bindgen Tests - Raindex Client', async f await raindexClient.getOrderByHash(1, CHAIN_ID_1_ORDERBOOK_ADDRESS, BYTES32_0123) ); const result = extractWasmEncodedData(await order.getTradesList()); - assert.equal(result.length, 1); - assert.equal(result[0].id, mockOrderTradesList[0].id); - assert.equal(result[0].orderHash, mockOrderTradesList[0].order.orderHash); - assert.equal(result[0].timestamp, BigInt(mockOrderTradesList[0].timestamp)); - assert.equal(result[0].orderbook, mockOrderTradesList[0].orderbook.id.toLowerCase()); + assert.equal(result.trades.length, 1); + assert.equal(result.totalCount, 1); + assert.ok(result.summary); + assert.equal(result.trades[0].id, mockOrderTradesList[0].id); + assert.equal(result.trades[0].orderHash, mockOrderTradesList[0].order.orderHash); + assert.equal(result.trades[0].timestamp, BigInt(mockOrderTradesList[0].timestamp)); assert.equal( - result[0].outputVaultBalanceChange.amount, + result.trades[0].orderbook.toLowerCase(), + mockOrderTradesList[0].orderbook.id.toLowerCase() + ); + assert.equal( + result.trades[0].outputVaultBalanceChange.amount.asHex(), mockOrderTradesList[0].outputVaultBalanceChange.amount ); assert.equal( - result[0].outputVaultBalanceChange.vaultId, + result.trades[0].outputVaultBalanceChange.vaultId, BigInt(mockOrderTradesList[0].outputVaultBalanceChange.vault.vaultId) ); assert.equal( - result[0].outputVaultBalanceChange.token.id, + result.trades[0].outputVaultBalanceChange.token.id, mockOrderTradesList[0].outputVaultBalanceChange.vault.token.id ); assert.equal( - result[0].outputVaultBalanceChange.token.address, + result.trades[0].outputVaultBalanceChange.token.address, mockOrderTradesList[0].outputVaultBalanceChange.vault.token.address ); assert.equal( - result[0].outputVaultBalanceChange.token.name, + result.trades[0].outputVaultBalanceChange.token.name, mockOrderTradesList[0].outputVaultBalanceChange.vault.token.name ); assert.equal( - result[0].outputVaultBalanceChange.token.symbol, + result.trades[0].outputVaultBalanceChange.token.symbol, mockOrderTradesList[0].outputVaultBalanceChange.vault.token.symbol ); assert.equal( - result[0].outputVaultBalanceChange.token.decimals, + result.trades[0].outputVaultBalanceChange.token.decimals, BigInt(mockOrderTradesList[0].outputVaultBalanceChange.vault.token.decimals ?? 0) ); assert.equal( - result[0].inputVaultBalanceChange.amount, + result.trades[0].inputVaultBalanceChange.amount.asHex(), mockOrderTradesList[0].inputVaultBalanceChange.amount ); assert.equal( - result[0].inputVaultBalanceChange.vaultId, + result.trades[0].inputVaultBalanceChange.vaultId, BigInt(mockOrderTradesList[0].inputVaultBalanceChange.vault.vaultId) ); assert.equal( - result[0].inputVaultBalanceChange.token.id, + result.trades[0].inputVaultBalanceChange.token.id, mockOrderTradesList[0].inputVaultBalanceChange.vault.token.id ); assert.equal( - result[0].inputVaultBalanceChange.token.address, + result.trades[0].inputVaultBalanceChange.token.address, mockOrderTradesList[0].inputVaultBalanceChange.vault.token.address ); assert.equal( - result[0].inputVaultBalanceChange.token.name, + result.trades[0].inputVaultBalanceChange.token.name, mockOrderTradesList[0].inputVaultBalanceChange.vault.token.name ); assert.equal( - result[0].inputVaultBalanceChange.token.symbol, + result.trades[0].inputVaultBalanceChange.token.symbol, mockOrderTradesList[0].inputVaultBalanceChange.vault.token.symbol ); assert.equal( - result[0].inputVaultBalanceChange.token.decimals, + result.trades[0].inputVaultBalanceChange.token.decimals, BigInt(mockOrderTradesList[0].inputVaultBalanceChange.vault.token.decimals ?? 0) ); - assert.equal(result[0].transaction.id, mockOrderTradesList[0].tradeEvent.transaction.id); assert.equal( - result[0].transaction.from, + result.trades[0].transaction.id, + mockOrderTradesList[0].tradeEvent.transaction.id + ); + assert.equal( + result.trades[0].transaction.from, mockOrderTradesList[0].tradeEvent.transaction.from ); assert.equal( - result[0].transaction.blockNumber, + result.trades[0].transaction.blockNumber, BigInt(mockOrderTradesList[0].tradeEvent.transaction.blockNumber) ); assert.equal( - result[0].transaction.timestamp, + result.trades[0].transaction.timestamp, BigInt(mockOrderTradesList[0].tradeEvent.transaction.timestamp) ); }); @@ -1188,40 +1199,6 @@ describe('Rain Orderbook JS API Package Bindgen Tests - Raindex Client', async f ); assert.equal(result.orderbook, mockTrade.orderbook.id.toLowerCase()); }); - - it('should get trade count', async function () { - await mockServer - .forPost('/sg1') - .once() - .thenReply(200, JSON.stringify({ data: { orders: [order1] } })); - await mockServer - .forPost('/sg1') - .once() - .thenReply(200, JSON.stringify({ data: { orders: [order1] } })); - await mockServer.forPost('/sg1').thenReply( - 200, - JSON.stringify({ - data: { - trades: mockOrderTradesList - } - }) - ); - await mockServer.forPost('/sg1').thenReply( - 200, - JSON.stringify({ - data: { - trades: [] - } - }) - ); - - const raindexClient = extractWasmEncodedData(await RaindexClient.new([YAML])); - const order = extractWasmEncodedData( - await raindexClient.getOrderByHash(1, CHAIN_ID_1_ORDERBOOK_ADDRESS, BYTES32_0123) - ); - const result = extractWasmEncodedData(await order.getTradeCount()); - assert.equal(result, 1); - }); }); }); diff --git a/packages/ui-components/src/__tests__/OrderTradesListTable.test.ts b/packages/ui-components/src/__tests__/OrderTradesListTable.test.ts index 96c224e8dd..7e303d42a7 100644 --- a/packages/ui-components/src/__tests__/OrderTradesListTable.test.ts +++ b/packages/ui-components/src/__tests__/OrderTradesListTable.test.ts @@ -140,10 +140,15 @@ vi.mock('@tanstack/svelte-query'); const mockOrder: RaindexOrder = { id: '1', - getTradeCount: vi.fn(), getTradesList: vi.fn() } as unknown as RaindexOrder; +const wrapInResult = (trades: RaindexTrade[]) => ({ + trades, + totalCount: BigInt(trades.length), + summary: {} +}); + test('renders table with correct data', async () => { const queryClient = new QueryClient(); @@ -153,7 +158,7 @@ test('renders table with correct data', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [mockTradeOrdersList] }, + data: { pages: [wrapInResult(mockTradeOrdersList)] }, status: 'success', isFetching: false, isFetched: true @@ -192,7 +197,7 @@ test('renders a debug button for each trade', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [mockTradeOrdersList] }, + data: { pages: [wrapInResult(mockTradeOrdersList)] }, status: 'success', isFetching: false, isFetched: true @@ -225,7 +230,7 @@ test('renders combined Transaction column with Sender and Tx', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [mockTradeOrdersList] }, + data: { pages: [wrapInResult(mockTradeOrdersList)] }, status: 'success', isFetching: false, isFetched: true @@ -260,7 +265,7 @@ test('renders Input column with token symbol and amount', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [mockTradeOrdersList] }, + data: { pages: [wrapInResult(mockTradeOrdersList)] }, status: 'success', isFetching: false, isFetched: true @@ -297,7 +302,7 @@ test('renders Output column with token symbol and amount', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any subscribe: (fn: (value: any) => void) => { fn({ - data: { pages: [mockTradeOrdersList] }, + data: { pages: [wrapInResult(mockTradeOrdersList)] }, status: 'success', isFetching: false, isFetched: true @@ -400,7 +405,7 @@ test('displays dash when output amount is zero (prevents division by zero)', asy mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: unknown) => void) => { fn({ - data: { pages: [mockTrades] }, + data: { pages: [wrapInResult(mockTrades)] }, status: 'success', isFetching: false, isFetched: true @@ -430,7 +435,7 @@ test('displays dash when input amount is zero', async () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: unknown) => void) => { fn({ - data: { pages: [mockTrades] }, + data: { pages: [wrapInResult(mockTrades)] }, status: 'success', isFetching: false, isFetched: true @@ -460,7 +465,7 @@ test('displays dash when both amounts are zero', async () => { mockQuery.createInfiniteQuery = vi.fn((__options, _queryClient) => ({ subscribe: (fn: (value: unknown) => void) => { fn({ - data: { pages: [mockTrades] }, + data: { pages: [wrapInResult(mockTrades)] }, status: 'success', isFetching: false, isFetched: true diff --git a/packages/ui-components/src/lib/components/charts/OrderTradesChart.svelte b/packages/ui-components/src/lib/components/charts/OrderTradesChart.svelte index 983beace6a..62065fbb75 100644 --- a/packages/ui-components/src/lib/components/charts/OrderTradesChart.svelte +++ b/packages/ui-components/src/lib/components/charts/OrderTradesChart.svelte @@ -58,9 +58,9 @@ $: query = createQuery({ queryKey: [QKEY_ORDER_TRADES_LIST, order.id], queryFn: async () => { - const data = await order.getTradesList(undefined, undefined, 1); + const data = await order.getTradesList(undefined, undefined, undefined); if (data.error) throw new Error(data.error.readableMsg); - return data.value; + return data.value.trades; } }); diff --git a/packages/ui-components/src/lib/components/tables/OrderTradesListTable.svelte b/packages/ui-components/src/lib/components/tables/OrderTradesListTable.svelte index 799745108f..0838678a37 100644 --- a/packages/ui-components/src/lib/components/tables/OrderTradesListTable.svelte +++ b/packages/ui-components/src/lib/components/tables/OrderTradesListTable.svelte @@ -2,12 +2,15 @@ import { createInfiniteQuery } from '@tanstack/svelte-query'; import TanstackAppTable from '../TanstackAppTable.svelte'; import { QKEY_ORDER_TRADES_LIST } from '../../queries/keys'; - import { DEFAULT_PAGE_SIZE } from '../../queries/constants'; import { TableBodyCell, TableHeadCell } from 'flowbite-svelte'; import { formatTimestampSecondsAsLocal } from '../../services/time'; import Hash, { HashType } from '../Hash.svelte'; import { BugOutline } from 'flowbite-svelte-icons'; - import type { RaindexOrder, RaindexTrade } from '@rainlanguage/orderbook'; + import type { + RaindexOrder, + RaindexTrade, + RaindexTradesListResult + } from '@rainlanguage/orderbook'; import TableTimeFilters from '../charts/TableTimeFilters.svelte'; import Tooltip from '../Tooltip.svelte'; @@ -25,44 +28,34 @@ queryFn: async ({ pageParam }: { pageParam: number }) => { tradesCount = undefined; - const [countResult, tradesResult] = await Promise.all([ - order.getTradeCount( - startTimestamp ? BigInt(startTimestamp) : undefined, - endTimestamp ? BigInt(endTimestamp) : undefined - ), - order.getTradesList( - startTimestamp ? BigInt(startTimestamp) : undefined, - endTimestamp ? BigInt(endTimestamp) : undefined, - pageParam + 1 - ) - ]); - if (countResult.error) throw new Error(countResult.error.readableMsg); - if (tradesResult.error) throw new Error(tradesResult.error.readableMsg); + const result = await order.getTradesList( + startTimestamp ? BigInt(startTimestamp) : undefined, + endTimestamp ? BigInt(endTimestamp) : undefined, + pageParam + 1 + ); + if (result.error) throw new Error(result.error.readableMsg); - const count = countResult.value; - const trades = tradesResult.value; + tradesCount = Number(result.value.totalCount); - if (typeof count === 'number') { - tradesCount = count; - } - - return trades; + return result.value; }, initialPageParam: 0, getNextPageParam: ( - lastPage: RaindexTrade[], - _allPages: RaindexTrade[][], + lastPage: RaindexTradesListResult, + allPages: RaindexTradesListResult[], lastPageParam: number ) => { - return lastPage.length === DEFAULT_PAGE_SIZE ? lastPageParam + 1 : undefined; + const fetchedCount = allPages.reduce((sum, p) => sum + p.trades.length, 0); + return fetchedCount < Number(lastPage.totalCount) ? lastPageParam + 1 : undefined; } }); - const AppTable = TanstackAppTable; + const AppTable = TanstackAppTable; page.trades} emptyMessage="No trades found" rowHoverable={false} queryKey={order.id}