diff --git a/AGENTS.md b/AGENTS.md index 35486a6..33ec102 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,9 @@ - `nix develop -c cargo fmt` - `nix develop -c rainix-rs-static` +## Submodules +- Never modify submodule code directly; request changes upstream and update the submodule pointer to the new commit + ## Code Rules - Never use `expect` or `unwrap` in production code; handle errors gracefully or exit with a message - Every route handler must log appropriately using tracing (request received, errors, key decisions) diff --git a/lib/rain.orderbook b/lib/rain.orderbook index ff9578c..a612a4f 160000 --- a/lib/rain.orderbook +++ b/lib/rain.orderbook @@ -1 +1 @@ -Subproject commit ff9578cfb61a42aa51205f4b2dcb6b81b5125c97 +Subproject commit a612a4f7168975f31a6ad892fb1704d8b5ead892 diff --git a/src/routes/trades/get_by_address.rs b/src/routes/trades/get_by_address.rs index 66140ad..db02ac2 100644 --- a/src/routes/trades/get_by_address.rs +++ b/src/routes/trades/get_by_address.rs @@ -1,10 +1,16 @@ +use super::{RaindexTradesDataSource, TradesDataSource}; use crate::auth::AuthenticatedKey; use crate::error::{ApiError, ApiErrorResponse}; use crate::fairings::{GlobalRateLimit, TracingSpan}; -use crate::types::common::ValidatedAddress; -use crate::types::trades::{TradesByAddressResponse, TradesPaginationParams}; +use crate::types::common::{TokenRef, ValidatedAddress}; +use crate::types::trades::{ + TradeByAddress, TradesByAddressResponse, TradesPagination, TradesPaginationParams, +}; +use alloy::primitives::{Address, FixedBytes}; +use rain_orderbook_common::raindex_client::types::{PaginationParams, TimeFilter}; use rocket::serde::json::Json; use rocket::State; +use std::str::FromStr; use tracing::Instrument; #[utoipa::path( @@ -36,10 +42,251 @@ pub async fn get_trades_by_address( async move { tracing::info!(address = ?address, params = ?params, "request received"); raindex - .run_with_client(move |_client| async move { todo!() }) + .run_with_client(move |client| async move { + let ds = RaindexTradesDataSource { client: &client }; + process_get_trades_by_address(&ds, address.0, params).await + }) .await .map_err(ApiError::from)? } .instrument(span.0) .await } + +pub(super) async fn process_get_trades_by_address( + ds: &dyn TradesDataSource, + address: Address, + params: TradesPaginationParams, +) -> Result, ApiError> { + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + + let pagination = PaginationParams::new( + Some( + page.try_into() + .map_err(|_| ApiError::BadRequest("page value too large".into()))?, + ), + Some( + page_size + .try_into() + .map_err(|_| ApiError::BadRequest("page_size value too large".into()))?, + ), + ); + let time_filter = TimeFilter::new(params.start_time, params.end_time); + + let result = ds + .get_trades_for_owner(address, pagination, time_filter) + .await?; + + let trades: Vec = result + .trades() + .iter() + .map(|trade| { + let tx_hash = trade.transaction().id(); + let input_vc = trade.input_vault_balance_change(); + let output_vc = trade.output_vault_balance_change(); + + let input_token_data = input_vc.token(); + let output_token_data = output_vc.token(); + + let order_hash = FixedBytes::<32>::from_str(&trade.order_hash().to_string()).ok(); + + let timestamp: u64 = trade.timestamp().try_into().map_err(|_| { + tracing::error!("timestamp does not fit in u64"); + ApiError::Internal("timestamp overflow".into()) + })?; + let block_number: u64 = + trade.transaction().block_number().try_into().map_err(|_| { + tracing::error!("block number does not fit in u64"); + ApiError::Internal("block number overflow".into()) + })?; + + Ok(TradeByAddress { + tx_hash, + input_amount: input_vc.formatted_amount(), + output_amount: output_vc.formatted_amount(), + input_token: TokenRef { + address: input_token_data.address(), + symbol: input_token_data.symbol().unwrap_or_default(), + decimals: input_token_data.decimals(), + }, + output_token: TokenRef { + address: output_token_data.address(), + symbol: output_token_data.symbol().unwrap_or_default(), + decimals: output_token_data.decimals(), + }, + order_hash, + timestamp, + block_number, + }) + }) + .collect::, ApiError>>()?; + + let total_trades = result.total_count(); + let total_pages = if page_size > 0 { + total_trades.div_ceil(u64::from(page_size)) + } else { + 0 + }; + let has_more = u64::from(page) < total_pages; + + Ok(Json(TradesByAddressResponse { + trades, + pagination: TradesPagination { + page, + page_size, + total_trades, + total_pages, + has_more, + }, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ApiError; + use crate::routes::order::test_fixtures::*; + use crate::test_helpers::{ + basic_auth_header, mock_invalid_raindex_config, seed_api_key, TestClientBuilder, + }; + use alloy::primitives::{address, B256}; + use async_trait::async_trait; + use rain_orderbook_common::raindex_client::trades::{RaindexTrade, RaindexTradesListResult}; + use rain_orderbook_common::raindex_client::types::{PaginationParams, TimeFilter}; + use rocket::http::{Header, Status}; + + struct MockTradesDataSource { + owner_result: Result, + } + + #[async_trait(?Send)] + impl TradesDataSource for MockTradesDataSource { + async fn get_trades_by_tx(&self, _tx_hash: B256) -> Result, ApiError> { + unimplemented!() + } + + async fn get_trades_for_owner( + &self, + _owner: Address, + _pagination: PaginationParams, + _time_filter: TimeFilter, + ) -> Result { + match &self.owner_result { + Ok(r) => Ok(r.clone()), + Err(e) => Err(e.clone()), + } + } + } + + #[rocket::async_test] + async fn test_process_success() { + let trade = mock_trade(); + let ds = MockTradesDataSource { + owner_result: Ok(RaindexTradesListResult::new(vec![trade], 1)), + }; + let params = TradesPaginationParams { + page: Some(1), + page_size: Some(20), + start_time: None, + end_time: None, + }; + let result = process_get_trades_by_address( + &ds, + address!("0000000000000000000000000000000000000001"), + params, + ) + .await + .unwrap(); + + let response = result.into_inner(); + assert_eq!(response.trades.len(), 1); + assert_eq!(response.pagination.total_trades, 1); + assert_eq!(response.pagination.total_pages, 1); + assert!(!response.pagination.has_more); + + let t = &response.trades[0]; + assert_eq!(t.timestamp, 1700001000); + assert_eq!(t.block_number, 100); + assert_eq!(t.input_amount, "0.500000"); + assert_eq!(t.output_amount, "-0.250000000000000000"); + assert_eq!(t.input_token.symbol, "USDC"); + assert_eq!(t.output_token.symbol, "WETH"); + } + + #[rocket::async_test] + async fn test_process_no_trades() { + let ds = MockTradesDataSource { + owner_result: Ok(RaindexTradesListResult::new(vec![], 0)), + }; + let params = TradesPaginationParams { + page: Some(1), + page_size: Some(20), + start_time: None, + end_time: None, + }; + let result = process_get_trades_by_address( + &ds, + address!("0000000000000000000000000000000000000001"), + params, + ) + .await + .unwrap(); + + let response = result.into_inner(); + assert!(response.trades.is_empty()); + assert_eq!(response.pagination.total_trades, 0); + assert_eq!(response.pagination.total_pages, 0); + assert!(!response.pagination.has_more); + } + + #[rocket::async_test] + async fn test_process_query_failure() { + let ds = MockTradesDataSource { + owner_result: Err(ApiError::Internal("subgraph error".into())), + }; + let params = TradesPaginationParams { + page: Some(1), + page_size: Some(20), + start_time: None, + end_time: None, + }; + let result = process_get_trades_by_address( + &ds, + address!("0000000000000000000000000000000000000001"), + params, + ) + .await; + assert!(matches!(result, Err(ApiError::Internal(_)))); + } + + #[rocket::async_test] + async fn test_401_without_auth() { + let client = TestClientBuilder::new().build().await; + let response = client + .get("/v1/trades/0x0000000000000000000000000000000000000001") + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn test_500_on_bad_config() { + let config = mock_invalid_raindex_config().await; + let client = TestClientBuilder::new() + .raindex_config(config) + .build() + .await; + let (key_id, secret) = seed_api_key(&client).await; + let header = basic_auth_header(&key_id, &secret); + let response = client + .get("/v1/trades/0x0000000000000000000000000000000000000001") + .header(Header::new("Authorization", header)) + .dispatch() + .await; + assert_eq!(response.status(), Status::InternalServerError); + let body: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap()).unwrap(); + assert_eq!(body["error"]["code"], "INTERNAL_ERROR"); + } +} diff --git a/src/routes/trades/get_by_tx.rs b/src/routes/trades/get_by_tx.rs index 06cf73b..cd04ee4 100644 --- a/src/routes/trades/get_by_tx.rs +++ b/src/routes/trades/get_by_tx.rs @@ -1,4 +1,4 @@ -use super::{RaindexTradesTxDataSource, TradesTxDataSource}; +use super::{RaindexTradesDataSource, TradesDataSource}; use crate::auth::AuthenticatedKey; use crate::error::{ApiError, ApiErrorResponse}; use crate::fairings::{GlobalRateLimit, TracingSpan}; @@ -44,7 +44,7 @@ pub async fn get_trades_by_tx( tracing::info!(tx_hash = ?tx_hash, "request received"); raindex .run_with_client(move |client| async move { - let trades_ds = RaindexTradesTxDataSource { client: &client }; + let trades_ds = RaindexTradesDataSource { client: &client }; let order_ds = crate::routes::order::RaindexOrderDataSource { client: &client }; process_get_trades_by_tx(&trades_ds, &order_ds, tx_hash.0).await }) @@ -56,7 +56,7 @@ pub async fn get_trades_by_tx( } pub(super) async fn process_get_trades_by_tx( - trades_ds: &dyn TradesTxDataSource, + trades_ds: &dyn TradesDataSource, order_ds: &dyn OrderDataSource, tx_hash: B256, ) -> Result, ApiError> { @@ -238,28 +238,38 @@ mod tests { use crate::test_helpers::{ basic_auth_header, mock_invalid_raindex_config, seed_api_key, TestClientBuilder, }; - use alloy::primitives::{address, Bytes}; + use alloy::primitives::{address, Address, Bytes}; use async_trait::async_trait; - use rain_orderbook_common::raindex_client::trades::RaindexTrade; + use rain_orderbook_common::raindex_client::trades::{RaindexTrade, RaindexTradesListResult}; + use rain_orderbook_common::raindex_client::types::{PaginationParams, TimeFilter}; use rocket::http::{Header, Status}; - struct MockTradesTxDataSource { + struct MockTradesDataSource { result: Result, ApiError>, } #[async_trait(?Send)] - impl TradesTxDataSource for MockTradesTxDataSource { + impl TradesDataSource for MockTradesDataSource { async fn get_trades_by_tx(&self, _tx_hash: B256) -> Result, ApiError> { match &self.result { Ok(trades) => Ok(trades.clone()), Err(e) => Err(e.clone()), } } + + async fn get_trades_for_owner( + &self, + _owner: Address, + _pagination: PaginationParams, + _time_filter: TimeFilter, + ) -> Result { + unimplemented!() + } } #[rocket::async_test] async fn test_process_success() { - let trades_ds = MockTradesTxDataSource { + let trades_ds = MockTradesDataSource { result: Ok(vec![mock_trade()]), }; let order_ds = MockOrderDataSource { @@ -294,7 +304,7 @@ mod tests { #[rocket::async_test] async fn test_process_tx_not_found() { - let trades_ds = MockTradesTxDataSource { result: Ok(vec![]) }; + let trades_ds = MockTradesDataSource { result: Ok(vec![]) }; let order_ds = MockOrderDataSource { orders: Ok(vec![]), trades: vec![], @@ -314,7 +324,7 @@ mod tests { #[rocket::async_test] async fn test_process_tx_not_indexed() { - let trades_ds = MockTradesTxDataSource { + let trades_ds = MockTradesDataSource { result: Err(ApiError::NotYetIndexed("not indexed".into())), }; let order_ds = MockOrderDataSource { @@ -336,7 +346,7 @@ mod tests { #[rocket::async_test] async fn test_process_query_failure() { - let trades_ds = MockTradesTxDataSource { + let trades_ds = MockTradesDataSource { result: Err(ApiError::Internal("subgraph error".into())), }; let order_ds = MockOrderDataSource { diff --git a/src/routes/trades/mod.rs b/src/routes/trades/mod.rs index 4de9d42..f509d5d 100644 --- a/src/routes/trades/mod.rs +++ b/src/routes/trades/mod.rs @@ -2,23 +2,32 @@ pub(crate) mod get_by_address; pub(crate) mod get_by_tx; use crate::error::ApiError; -use alloy::primitives::B256; +use alloy::primitives::{Address, B256}; use async_trait::async_trait; -use rain_orderbook_common::raindex_client::trades::RaindexTrade; +use rain_orderbook_common::raindex_client::trades::{RaindexTrade, RaindexTradesListResult}; +use rain_orderbook_common::raindex_client::types::{ + OrderbookIdentifierParams, PaginationParams, TimeFilter, +}; use rain_orderbook_common::raindex_client::{RaindexClient, RaindexError}; use rocket::Route; #[async_trait(?Send)] -pub(crate) trait TradesTxDataSource { +pub(crate) trait TradesDataSource { async fn get_trades_by_tx(&self, tx_hash: B256) -> Result, ApiError>; + async fn get_trades_for_owner( + &self, + owner: Address, + pagination: PaginationParams, + time_filter: TimeFilter, + ) -> Result; } -pub(crate) struct RaindexTradesTxDataSource<'a> { +pub(crate) struct RaindexTradesDataSource<'a> { pub client: &'a RaindexClient, } #[async_trait(?Send)] -impl TradesTxDataSource for RaindexTradesTxDataSource<'_> { +impl TradesDataSource for RaindexTradesDataSource<'_> { async fn get_trades_by_tx(&self, tx_hash: B256) -> Result, ApiError> { let orderbooks = self.client.get_all_orderbooks().map_err(|e| { tracing::error!(error = %e, "failed to get orderbooks"); @@ -53,6 +62,47 @@ impl TradesTxDataSource for RaindexTradesTxDataSource<'_> { } Ok(all_trades) } + + async fn get_trades_for_owner( + &self, + owner: Address, + pagination: PaginationParams, + time_filter: TimeFilter, + ) -> Result { + let orderbooks = self.client.get_all_orderbooks().map_err(|e| { + tracing::error!(error = %e, "failed to get orderbooks"); + ApiError::Internal("failed to get orderbooks".into()) + })?; + + let mut all_trades: Vec = Vec::new(); + let mut total_count: u64 = 0; + + for ob_cfg in orderbooks.values() { + let ob_id_params = + OrderbookIdentifierParams::new(ob_cfg.network.chain_id, ob_cfg.address.to_string()); + match self + .client + .get_trades_for_owner( + ob_id_params, + owner.to_string(), + pagination.clone(), + time_filter.clone(), + ) + .await + { + Ok(result) => { + all_trades.extend(result.trades()); + total_count += result.total_count(); + } + Err(e) => { + tracing::error!(error = %e, "failed to query trades for owner"); + return Err(ApiError::Internal("failed to query trades".into())); + } + } + } + + Ok(RaindexTradesListResult::new(all_trades, total_count)) + } } pub fn routes() -> Vec {