From bd9e1dac8b928eb2ab91bff311c6cdccf7b393c0 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Tue, 25 Aug 2026 12:37:00 +0300 Subject: [PATCH] feat: add registry market statistics --- Cargo.lock | 6 +- Cargo.toml | 2 +- crates/common/ARCHITECTURE.md | 6 +- crates/common/Cargo.toml | 1 + .../fetch_latest_trades_per_token/mod.rs | 118 +++ .../fetch_latest_trades_per_token/query.sql | 63 ++ crates/common/src/local_db/query/mod.rs | 1 + .../query/fetch_latest_trades_per_token.rs | 28 + .../src/raindex_client/local_db/query/mod.rs | 1 + .../src/raindex_client/markets/catalog.rs | 681 ++++++++++++ .../common/src/raindex_client/markets/mod.rs | 597 +++++++++++ .../src/raindex_client/markets/orderbook.rs | 988 ++++++++++++++++++ .../src/raindex_client/markets/trades.rs | 565 ++++++++++ .../src/raindex_client/markets/types.rs | 195 ++++ crates/common/src/raindex_client/mod.rs | 1 + .../common/src/raindex_client/order_quotes.rs | 101 +- crates/common/src/raindex_client/orders.rs | 20 + .../src/raindex_client/trades/get_all.rs | 453 +++++++- .../common/src/raindex_client/trades/mod.rs | 70 ++ crates/common/src/raindex_client/vaults.rs | 29 + .../src/types/order_takes_list_flattened.rs | 2 + crates/quote/src/error.rs | 2 + crates/quote/src/order_quotes.rs | 203 +++- crates/quote/src/quote.rs | 30 +- crates/quote/src/rpc.rs | 525 +++++++++- crates/subgraph/src/multi_raindex_client.rs | 2 + crates/subgraph/src/performance/apy.rs | 4 + .../src/performance/order_performance.rs | 4 + crates/subgraph/src/performance/vol.rs | 4 + .../src/raindex_client/order_trade.rs | 2 + .../src/raindex_client/performance.rs | 2 + crates/subgraph/src/types/common.rs | 3 + crates/subgraph/src/types/impls.rs | 2 + ...r_trade_test__vaults_query_gql_output.snap | 2 + ..._trades_test__vaults_query_gql_output.snap | 2 + .../raindex/test/js_api/raindexClient.test.ts | 6 + 36 files changed, 4654 insertions(+), 67 deletions(-) create mode 100644 crates/common/src/local_db/query/fetch_latest_trades_per_token/mod.rs create mode 100644 crates/common/src/local_db/query/fetch_latest_trades_per_token/query.sql create mode 100644 crates/common/src/raindex_client/local_db/query/fetch_latest_trades_per_token.rs create mode 100644 crates/common/src/raindex_client/markets/catalog.rs create mode 100644 crates/common/src/raindex_client/markets/mod.rs create mode 100644 crates/common/src/raindex_client/markets/orderbook.rs create mode 100644 crates/common/src/raindex_client/markets/trades.rs create mode 100644 crates/common/src/raindex_client/markets/types.rs diff --git a/Cargo.lock b/Cargo.lock index d7722f3628..e9b5509c72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5383,13 +5383,14 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rain-erc" -version = "0.1.1" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1743f958ef5d54d361f3b7e2ff9109adfe0549934c46fac729f8d69ec09677" +checksum = "0dde8f670648bca26ec1fc4565a7f32631c040204137aee953b8444c6d0d7ff2" dependencies = [ "alloy", "getrandom 0.2.16", "getrandom 0.3.3", + "serde", "thiserror 1.0.69", ] @@ -5608,6 +5609,7 @@ dependencies = [ "itertools 0.14.0", "once_cell", "proptest", + "rain-erc", "rain-error-decoding", "rain-math-float", "rain-metaboard-subgraph", diff --git a/Cargo.toml b/Cargo.toml index 4506ae851b..f849d8c77a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ derive_builder = "0.20.0" thirtyfour = "0.31.0" test-context = "0.3.0" portpicker = "0.1.1" -rain-erc = "0.1.1" +rain-erc = "0.1.5" rain-math-float = "0.1.7" rain-error-decoding = "0.1.2" # Direct dep required: the #[wasm_bindgen] macro emits `::wasm_bindgen` paths, and diff --git a/crates/common/ARCHITECTURE.md b/crates/common/ARCHITECTURE.md index 7b5c197631..1d3f168573 100644 --- a/crates/common/ARCHITECTURE.md +++ b/crates/common/ARCHITECTURE.md @@ -38,7 +38,11 @@ require its `COMMIT_SHA` env var. prepare batch withdraw calldata; expose WASM‑friendly structs. The `local_db/` subtree is split into `state.rs` (runtime state, query routing via `LocalDbState`/`QuerySource`/`SyncReadiness`) and `status.rs` (UI - status‑reporting types). + status‑reporting types). The `markets/` subtree discovers direct quote-token + pairs from active indexed orders and assembles normalized order books, trades, + and rolling statistics without application-specific token semantics. Registry + token data identifies each network's quote token and enriches indexed + metadata; it does not decide which base tokens are listed. - `dotrain_order` — Parse and validate a DOTRAIN config; compose scenarios/deployments to Rainlang; fetch authoring metadata and pragma words; merge additional settings. diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 205a09e0c5..c3fe8011b8 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -43,6 +43,7 @@ csv = { workspace = true } chrono = { workspace = true } futures = { workspace = true } rain-error-decoding = { workspace = true } +rain-erc = { workspace = true } rain-interpreter-eval = { workspace = true } wasm-bindgen = { workspace = true } wasm-bindgen-utils = { workspace = true } diff --git a/crates/common/src/local_db/query/fetch_latest_trades_per_token/mod.rs b/crates/common/src/local_db/query/fetch_latest_trades_per_token/mod.rs new file mode 100644 index 0000000000..3f65059ff9 --- /dev/null +++ b/crates/common/src/local_db/query/fetch_latest_trades_per_token/mod.rs @@ -0,0 +1,118 @@ +use crate::local_db::query::fetch_order_trades::LocalDbOrderTrade; +use crate::local_db::query::{SqlBuildError, SqlStatement, SqlValue}; +use alloy::primitives::Address; + +const QUERY_TEMPLATE: &str = include_str!("query.sql"); +const RAINDEXES_CLAUSE: &str = "/*RAINDEXES_CLAUSE*/"; +const RAINDEXES_CLAUSE_BODY: &str = "AND tws.raindex_address IN ({list})"; +const PAIR_CLAUSE: &str = "/*PAIR_CLAUSE*/"; + +#[derive(Debug, Clone)] +pub struct FetchLatestTradesPerTokenArgs { + pub chain_id: u32, + pub raindex_addresses: Vec
, + pub quote_token: Address, + pub base_tokens: Vec
, +} + +pub fn build_fetch_latest_trades_per_token_stmt( + args: &FetchLatestTradesPerTokenArgs, +) -> Result { + let mut stmt = SqlStatement::new(QUERY_TEMPLATE); + + let quote_partition = push_param(&mut stmt, args.quote_token); + stmt.replace("/*QUOTE_PARTITION*/", "e_partition)?; + + let chain_id = push_param(&mut stmt, args.chain_id); + stmt.replace("/*CHAIN_ID*/", &chain_id)?; + + let mut raindexes = args.raindex_addresses.clone(); + raindexes.sort_unstable(); + raindexes.dedup(); + stmt.bind_list_clause( + RAINDEXES_CLAUSE, + RAINDEXES_CLAUSE_BODY, + raindexes.into_iter().map(SqlValue::from), + )?; + + let mut base_tokens = args.base_tokens.clone(); + base_tokens.sort_unstable(); + base_tokens.dedup(); + if base_tokens.is_empty() { + stmt.replace(PAIR_CLAUSE, "AND 1 = 0")?; + return Ok(stmt); + } + + let input_quote = push_param(&mut stmt, args.quote_token); + let output_bases = push_list(&mut stmt, &base_tokens); + let output_quote = push_param(&mut stmt, args.quote_token); + let input_bases = push_list(&mut stmt, &base_tokens); + stmt.replace( + PAIR_CLAUSE, + &format!( + "AND ((tws.input_token = {input_quote} AND tws.output_token IN ({output_bases})) \ + OR (tws.output_token = {output_quote} AND tws.input_token IN ({input_bases})))" + ), + )?; + Ok(stmt) +} + +fn push_param(stmt: &mut SqlStatement, value: impl Into) -> String { + let placeholder = format!("?{}", stmt.params().len() + 1); + stmt.push(value.into()); + placeholder +} + +fn push_list(stmt: &mut SqlStatement, values: &[Address]) -> String { + values + .iter() + .map(|value| push_param(stmt, *value)) + .collect::>() + .join(", ") +} + +pub type LatestTradeRow = LocalDbOrderTrade; + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + + #[test] + fn query_selects_one_latest_trade_per_base_token() { + let quote = address!("2222222222222222222222222222222222222222"); + let base_a = address!("1111111111111111111111111111111111111111"); + let base_b = address!("3333333333333333333333333333333333333333"); + let raindex = address!("4444444444444444444444444444444444444444"); + + let stmt = build_fetch_latest_trades_per_token_stmt(&FetchLatestTradesPerTokenArgs { + chain_id: 8453, + raindex_addresses: vec![raindex, raindex], + quote_token: quote, + base_tokens: vec![base_b, base_a, base_a], + }) + .unwrap(); + + assert!(stmt.sql.contains("ROW_NUMBER() OVER")); + assert!(stmt.sql.contains("market_rank = 1")); + assert!(stmt.sql.contains("tws.raindex_address IN (?3)")); + assert!(stmt.sql.contains("tws.output_token IN (?5, ?6)")); + assert!(stmt.sql.contains("tws.input_token IN (?8, ?9)")); + assert_eq!(stmt.params().len(), 9); + assert!(!stmt.sql.contains("/*")); + } + + #[test] + fn empty_base_tokens_build_a_match_none_query() { + let stmt = build_fetch_latest_trades_per_token_stmt(&FetchLatestTradesPerTokenArgs { + chain_id: 8453, + raindex_addresses: vec![], + quote_token: Address::ZERO, + base_tokens: vec![], + }) + .unwrap(); + + assert!(stmt.sql.contains("AND 1 = 0")); + assert!(!stmt.sql.contains("/*")); + } +} diff --git a/crates/common/src/local_db/query/fetch_latest_trades_per_token/query.sql b/crates/common/src/local_db/query/fetch_latest_trades_per_token/query.sql new file mode 100644 index 0000000000..13b57755dd --- /dev/null +++ b/crates/common/src/local_db/query/fetch_latest_trades_per_token/query.sql @@ -0,0 +1,63 @@ +WITH ranked_trades AS ( + SELECT + tws.*, + ROW_NUMBER() OVER ( + PARTITION BY CASE + WHEN tws.input_token = /*QUOTE_PARTITION*/ THEN tws.output_token + ELSE tws.input_token + END + ORDER BY + tws.block_timestamp DESC, + tws.block_number DESC, + tws.log_index DESC, + tws.trade_kind, + tws.trade_side + ) AS market_rank + FROM derived_trades tws + WHERE tws.chain_id = /*CHAIN_ID*/ + /*RAINDEXES_CLAUSE*/ + /*PAIR_CLAUSE*/ +) +SELECT + tws.chain_id, + tws.trade_kind, + tws.raindex_address AS raindex, + 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, + tws.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, + tws.output_running_balance, + tws.trade_id +FROM ranked_trades tws +LEFT JOIN erc20_tokens tok_in + ON tok_in.chain_id = tws.chain_id + AND tok_in.raindex_address = tws.raindex_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.raindex_address = tws.raindex_address + AND tok_out.token_address = tws.output_token +WHERE tws.market_rank = 1 +ORDER BY + tws.block_timestamp DESC, + tws.block_number DESC, + tws.log_index DESC, + tws.trade_kind, + tws.trade_side; diff --git a/crates/common/src/local_db/query/mod.rs b/crates/common/src/local_db/query/mod.rs index 1b292d20a6..ef317e1518 100644 --- a/crates/common/src/local_db/query/mod.rs +++ b/crates/common/src/local_db/query/mod.rs @@ -7,6 +7,7 @@ pub mod fetch_all_tokens; pub mod fetch_db_metadata; pub mod fetch_erc20_tokens_by_addresses; pub mod fetch_last_synced_block; +pub mod fetch_latest_trades_per_token; pub mod fetch_order_trades; pub mod fetch_order_trades_count; pub mod fetch_order_vaults_volume; diff --git a/crates/common/src/raindex_client/local_db/query/fetch_latest_trades_per_token.rs b/crates/common/src/raindex_client/local_db/query/fetch_latest_trades_per_token.rs new file mode 100644 index 0000000000..378633039d --- /dev/null +++ b/crates/common/src/raindex_client/local_db/query/fetch_latest_trades_per_token.rs @@ -0,0 +1,28 @@ +use crate::local_db::query::fetch_latest_trades_per_token::{ + build_fetch_latest_trades_per_token_stmt, FetchLatestTradesPerTokenArgs, LatestTradeRow, +}; +use crate::local_db::query::{LocalDbQueryError, LocalDbQueryExecutor}; +use crate::utils::timing::Timing; + +pub async fn fetch_latest_trades_per_token( + exec: &E, + args: FetchLatestTradesPerTokenArgs, +) -> Result, LocalDbQueryError> { + if args.base_tokens.is_empty() { + return Ok(Vec::new()); + } + let started = Timing::now(); + let base_tokens_count = args.base_tokens.len(); + let raindexes_count = args.raindex_addresses.len(); + let stmt = build_fetch_latest_trades_per_token_stmt(&args)?; + let trades = exec.query_json::>(&stmt).await?; + tracing::info!( + chain_id = args.chain_id, + base_tokens_count, + raindexes_count, + rows = trades.len(), + duration_ms = started.elapsed_ms(), + "local DB latest market trades fetch completed" + ); + Ok(trades) +} 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 99659a8310..b291d6b76c 100644 --- a/crates/common/src/raindex_client/local_db/query/mod.rs +++ b/crates/common/src/raindex_client/local_db/query/mod.rs @@ -3,6 +3,7 @@ pub mod create_tables; pub mod fetch_all_tokens; pub mod fetch_erc20_tokens_by_addresses; pub mod fetch_last_synced_block; +pub mod fetch_latest_trades_per_token; pub mod fetch_order_trades; pub mod fetch_order_trades_count; pub mod fetch_order_vaults_volume; diff --git a/crates/common/src/raindex_client/markets/catalog.rs b/crates/common/src/raindex_client/markets/catalog.rs new file mode 100644 index 0000000000..db9510c635 --- /dev/null +++ b/crates/common/src/raindex_client/markets/catalog.rs @@ -0,0 +1,681 @@ +use super::*; +use crate::raindex_client::vaults::RaindexVaultToken; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IndexedToken { + address: Address, + name: Option, + symbol: Option, + decimals: u8, +} + +impl From for IndexedToken { + fn from(token: RaindexVaultToken) -> Self { + Self { + address: token.raw_address(), + name: token.raw_name().map(ToOwned::to_owned), + symbol: token.raw_symbol().map(ToOwned::to_owned), + decimals: token.raw_decimals(), + } + } +} + +#[derive(Debug, Clone)] +struct IndexedOrder { + chain_id: u32, + raindex: Address, + inputs: Vec, + outputs: Vec, +} + +impl From for IndexedOrder { + fn from(order: RaindexOrder) -> Self { + Self { + chain_id: order.chain_id(), + raindex: order.raw_raindex(), + inputs: order + .input_vaults() + .iter() + .map(|vault| vault.token().into()) + .collect(), + outputs: order + .output_vaults() + .iter() + .map(|vault| vault.token().into()) + .collect(), + } + } +} + +struct MarketAccumulator { + base: RaindexMarketToken, + quote: RaindexMarketToken, + raindex_addresses: BTreeSet
, +} + +pub(super) async fn discover_markets( + client: &RaindexClient, + options: &MarketListOptions, +) -> Result, RaindexError> { + let registry_tokens = client.get_all_tokens()?.into_values().collect::>(); + let quotes = quote_tokens(®istry_tokens, options)?; + if quotes.is_empty() { + return Ok(Vec::new()); + } + + let chain_ids = quotes.keys().copied().collect::>(); + let quote_addresses = quotes + .values() + .map(|quote| quote.address) + .collect::>(); + let orders = fetch_active_orders(client, chain_ids, quote_addresses).await?; + markets_from_indexed_orders( + orders.into_iter().map(IndexedOrder::from), + ®istry_tokens, + "es, + options, + ) +} + +async fn fetch_active_orders( + client: &RaindexClient, + chain_ids: Vec, + quote_addresses: Vec
, +) -> Result, RaindexError> { + let mut orders = Vec::new(); + for filters in active_order_filters(quote_addresses) { + orders.extend(fetch_market_orders_paginated(client, chain_ids.clone(), filters).await?); + } + Ok(orders) +} + +fn active_order_filters(quote_addresses: Vec
) -> [GetOrdersFilters; 2] { + [ + GetOrdersFilters { + active: Some(true), + tokens: Some(GetOrdersTokenFilter { + inputs: Some(quote_addresses.clone()), + outputs: None, + }), + ..Default::default() + }, + GetOrdersFilters { + active: Some(true), + tokens: Some(GetOrdersTokenFilter { + inputs: None, + outputs: Some(quote_addresses), + }), + ..Default::default() + }, + ] +} + +fn quote_tokens( + tokens: &[TokenCfg], + options: &MarketListOptions, +) -> Result, RaindexError> { + let selected_chains = options + .chain_ids + .as_ref() + .map(|ids| ids.iter().copied().collect::>()); + let mut quotes = BTreeMap::>::new(); + for token in tokens.iter().filter(|token| { + extension_bool(token, MARKET_QUOTE_EXTENSION) + && selected_chains + .as_ref() + .is_none_or(|chains| chains.contains(&token.network.chain_id)) + }) { + quotes + .entry(token.network.chain_id) + .or_default() + .entry(token.address) + .or_insert_with(|| token.clone()); + } + + quotes + .into_iter() + .map(|(chain_id, tokens)| match tokens.into_values().collect::>().as_slice() { + [quote] => Ok((chain_id, quote.clone())), + _ => Err(RaindexError::PreflightError(format!( + "multiple tokens with {MARKET_QUOTE_EXTENSION}=true are configured for chain {chain_id}" + ))), + }) + .collect() +} + +fn markets_from_indexed_orders( + orders: impl IntoIterator, + registry_tokens: &[TokenCfg], + quotes: &BTreeMap, + options: &MarketListOptions, +) -> Result, RaindexError> { + let registry_lookup = registry_token_lookup(registry_tokens)?; + let mut accumulators = BTreeMap::<(u32, Address), MarketAccumulator>::new(); + + for order in orders { + let Some(quote_cfg) = quotes.get(&order.chain_id) else { + continue; + }; + let quote_address = quote_cfg.address; + let quote = market_token(quote_cfg)?; + + if order + .inputs + .iter() + .any(|token| token.address == quote_address) + { + for token in order + .outputs + .iter() + .filter(|token| token.address != quote_address) + { + add_market( + &mut accumulators, + order.chain_id, + order.raindex, + token, + quote.clone(), + ®istry_lookup, + )?; + } + } + if order + .outputs + .iter() + .any(|token| token.address == quote_address) + { + for token in order + .inputs + .iter() + .filter(|token| token.address != quote_address) + { + add_market( + &mut accumulators, + order.chain_id, + order.raindex, + token, + quote.clone(), + ®istry_lookup, + )?; + } + } + } + + let selected_tickers = options + .ticker_ids + .as_ref() + .map(|ids| ids.iter().cloned().collect::>()); + let mut markets = accumulators + .into_values() + .map(|accumulator| { + let id = format!( + "{}:{:#x}:{:#x}", + accumulator.base.chain_id, accumulator.base.address, accumulator.quote.address + ); + let ticker_id = format!( + "{:#x}_{:#x}", + accumulator.base.address, accumulator.quote.address + ); + RaindexMarket { + id, + ticker_id, + chain_id: accumulator.base.chain_id, + base: accumulator.base, + quote: accumulator.quote, + raindex_addresses: accumulator.raindex_addresses.into_iter().collect(), + } + }) + .filter(|market| { + selected_tickers + .as_ref() + .is_none_or(|tickers| tickers.contains(&market.ticker_id)) + }) + .collect::>(); + markets.sort_by(|a, b| { + (a.chain_id, a.base.symbol.as_str(), a.base.address).cmp(&( + b.chain_id, + b.base.symbol.as_str(), + b.base.address, + )) + }); + Ok(markets) +} + +fn add_market( + markets: &mut BTreeMap<(u32, Address), MarketAccumulator>, + chain_id: u32, + raindex: Address, + indexed_token: &IndexedToken, + quote: RaindexMarketToken, + registry_lookup: &HashMap<(u32, Address), TokenCfg>, +) -> Result<(), RaindexError> { + let base = match registry_lookup.get(&(chain_id, indexed_token.address)) { + Some(token) => market_token(token)?, + None => indexed_market_token(chain_id, indexed_token), + }; + if base.address == quote.address { + return Ok(()); + } + markets + .entry((chain_id, base.address)) + .and_modify(|market| { + market.raindex_addresses.insert(raindex); + }) + .or_insert_with(|| MarketAccumulator { + base, + quote, + raindex_addresses: BTreeSet::from([raindex]), + }); + Ok(()) +} + +fn registry_token_lookup( + tokens: &[TokenCfg], +) -> Result, RaindexError> { + let mut sorted = tokens.to_vec(); + sorted.sort_by_key(|token| (token.network.chain_id, token.address, token.key.clone())); + let mut lookup = sorted + .iter() + .map(|token| ((token.network.chain_id, token.address), token.clone())) + .collect::>(); + + // Variant declarations intentionally override an exact-address registry entry: + // an indexed legacy or underlying token still belongs to the declared canonical market. + for token in sorted { + for address in [ + extension_address(&token, "unwrappedAddress")?, + extension_address(&token, "legacyAddress")?, + ] + .into_iter() + .flatten() + { + lookup.insert((token.network.chain_id, address), token.clone()); + } + } + Ok(lookup) +} + +fn indexed_market_token(chain_id: u32, token: &IndexedToken) -> RaindexMarketToken { + let address = token.address; + RaindexMarketToken { + chain_id, + address, + name: token + .name + .clone() + .or_else(|| token.symbol.clone()) + .unwrap_or_else(|| format!("{address:#x}")), + symbol: token + .symbol + .clone() + .or_else(|| token.name.clone()) + .unwrap_or_else(|| format!("{address:#x}")), + decimals: Some(token.decimals), + logo_uri: None, + extensions: None, + unwrapped_address: None, + legacy_address: None, + receipt_address: None, + variants: vec![address], + } +} + +fn market_token(token: &TokenCfg) -> Result { + let unwrapped_address = extension_address(token, "unwrappedAddress")?; + let legacy_address = extension_address(token, "legacyAddress")?; + let receipt_address = extension_address(token, "receiptAddress")?; + let mut variants = [Some(token.address), unwrapped_address, legacy_address] + .into_iter() + .flatten() + .collect::>(); + variants.sort_unstable(); + variants.dedup(); + + Ok(RaindexMarketToken { + chain_id: token.network.chain_id, + address: token.address, + name: token.label.clone().unwrap_or_else(|| token.key.clone()), + symbol: token.symbol.clone().unwrap_or_else(|| token.key.clone()), + decimals: token.decimals, + logo_uri: token.logo_uri.as_ref().map(ToString::to_string), + extensions: token.extensions.clone(), + unwrapped_address, + legacy_address, + receipt_address, + variants, + }) +} + +fn extension_string<'a>(token: &'a TokenCfg, key: &str) -> Option<&'a str> { + token + .extensions + .as_ref() + .and_then(|extensions| extensions.get(key)) + .and_then(Value::as_str) +} + +fn extension_bool(token: &TokenCfg, key: &str) -> bool { + token + .extensions + .as_ref() + .and_then(|extensions| extensions.get(key)) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn extension_address(token: &TokenCfg, key: &str) -> Result, RaindexError> { + extension_string(token, key) + .map(|address| { + address.parse::
().map_err(|error| { + RaindexError::PreflightError(format!( + "token {} has an invalid {key}: {error}", + token.key + )) + }) + }) + .transpose() +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use httpmock::MockServer; + + const USDC: &str = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; + const CANONICAL: &str = "0xfb5b41acdba20a3230f84be995173cfb98b8d6e7"; + const UNDERLYING: &str = "0x7271a3c91bb6070ed09333b84a815949d4f16d14"; + const GENERIC: &str = "0x1111111111111111111111111111111111111111"; + const WETH: &str = "0x4200000000000000000000000000000000000006"; + const RAINDEX: &str = "0xe522cb4a5fcb2eb31a52ff41a4653d85a4fd7c9d"; + + async fn client_with_tokens(tokens: Value) -> RaindexClient { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method("GET").path("/tokens"); + then.status(200).json_body(serde_json::json!({ + "name": "Markets", + "timestamp": "2026-01-01T00:00:00Z", + "version": { "major": 1, "minor": 0, "patch": 0 }, + "tokens": tokens + })); + }) + .await; + let yaml = format!( + r#" +version: 6 +networks: + base: + rpcs: + - http://localhost:8545 + chain-id: 8453 +subgraphs: + base: http://localhost:8080/subgraph +raindexes: + base: + address: {RAINDEX} + network: base + subgraph: base + deployment-block: 1 +using-tokens-from: + - {}/tokens +"#, + server.base_url() + ); + RaindexClient::new(vec![yaml], None, None).await.unwrap() + } + + fn token(address: &str, symbol: &str, extensions: Value) -> Value { + serde_json::json!({ + "chainId": 8453, + "address": address, + "name": symbol, + "symbol": symbol, + "decimals": 18, + "logoURI": format!("https://example.com/{symbol}.png"), + "extensions": extensions + }) + } + + fn indexed(address: &str, symbol: &str, decimals: u8) -> IndexedToken { + IndexedToken { + address: address.parse().unwrap(), + name: Some(symbol.to_string()), + symbol: Some(symbol.to_string()), + decimals, + } + } + + fn order(inputs: Vec, outputs: Vec) -> IndexedOrder { + IndexedOrder { + chain_id: 8453, + raindex: RAINDEX.parse().unwrap(), + inputs, + outputs, + } + } + + async fn registry_parts(tokens: Value) -> (Vec, BTreeMap) { + let client = client_with_tokens(tokens).await; + let tokens = client + .get_all_tokens() + .unwrap() + .into_values() + .collect::>(); + let quotes = quote_tokens(&tokens, &MarketListOptions::default()).unwrap(); + (tokens, quotes) + } + + #[test] + fn discovery_queries_only_active_orders() { + let quote = USDC.parse().unwrap(); + let filters = active_order_filters(vec![quote]); + assert!(filters.iter().all(|filter| filter.active == Some(true))); + assert_eq!( + filters[0] + .tokens + .as_ref() + .and_then(|tokens| tokens.inputs.as_ref()), + Some(&vec![quote]) + ); + assert_eq!( + filters[1] + .tokens + .as_ref() + .and_then(|tokens| tokens.outputs.as_ref()), + Some(&vec![quote]) + ); + } + + #[tokio::test] + async fn discovers_direct_quote_pairs_and_ignores_unrelated_pairs() { + let (registry, quotes) = registry_parts(serde_json::json!([token( + USDC, + "USDC", + serde_json::json!({ "marketQuote": true }) + ),])) + .await; + let orders = vec![ + order( + vec![indexed(USDC, "USDC", 6)], + vec![indexed(GENERIC, "GEN", 8)], + ), + order( + vec![indexed(WETH, "WETH", 18)], + vec![indexed(GENERIC, "GEN", 8)], + ), + ]; + + let markets = + markets_from_indexed_orders(orders, ®istry, "es, &MarketListOptions::default()) + .unwrap(); + + assert_eq!(markets.len(), 1); + assert_eq!(markets[0].base.address, GENERIC.parse::
().unwrap()); + assert_eq!(markets[0].base.symbol, "GEN"); + assert_eq!(markets[0].base.decimals, Some(8)); + assert!(markets[0].base.logo_uri.is_none()); + } + + #[tokio::test] + async fn registry_enriches_and_canonicalizes_an_indexed_variant() { + let (registry, quotes) = registry_parts(serde_json::json!([ + token(USDC, "USDC", serde_json::json!({ "marketQuote": true })), + token( + CANONICAL, + "WRAPPED", + serde_json::json!({ + "unwrappedAddress": UNDERLYING, + "opaqueMetadata": "preserved" + }) + ) + ])) + .await; + let orders = vec![order( + vec![indexed(UNDERLYING, "TOKEN", 18)], + vec![indexed(USDC, "USDC", 6)], + )]; + + let markets = + markets_from_indexed_orders(orders, ®istry, "es, &MarketListOptions::default()) + .unwrap(); + + assert_eq!(markets.len(), 1); + assert_eq!( + markets[0].base.address, + CANONICAL.parse::
().unwrap() + ); + assert_eq!(markets[0].base.symbol, "WRAPPED"); + assert_eq!( + markets[0] + .base + .extensions + .as_ref() + .and_then(|extensions| extensions.get("opaqueMetadata")) + .and_then(Value::as_str), + Some("preserved") + ); + assert!(markets[0].base.logo_uri.is_some()); + } + + #[tokio::test] + async fn deduplicates_markets_and_collects_only_contributing_raindexes() { + let second_raindex = "0x2222222222222222222222222222222222222222"; + let (registry, quotes) = registry_parts(serde_json::json!([token( + USDC, + "USDC", + serde_json::json!({ "marketQuote": true }) + ),])) + .await; + let mut second = order( + vec![indexed(GENERIC, "GEN", 8)], + vec![indexed(USDC, "USDC", 6)], + ); + second.raindex = second_raindex.parse().unwrap(); + + let markets = markets_from_indexed_orders( + vec![ + order( + vec![indexed(USDC, "USDC", 6)], + vec![indexed(GENERIC, "GEN", 8)], + ), + second, + ], + ®istry, + "es, + &MarketListOptions::default(), + ) + .unwrap(); + + assert_eq!(markets.len(), 1); + assert_eq!(markets[0].raindex_addresses.len(), 2); + } + + #[tokio::test] + async fn ticker_filter_is_applied_after_active_market_discovery() { + let (registry, quotes) = registry_parts(serde_json::json!([token( + USDC, + "USDC", + serde_json::json!({ "marketQuote": true }) + ),])) + .await; + let ticker_id = format!("{GENERIC}_{USDC}"); + let options = MarketListOptions { + ticker_ids: Some(vec![ticker_id]), + ..Default::default() + }; + + let markets = markets_from_indexed_orders( + vec![order( + vec![indexed(USDC, "USDC", 6)], + vec![indexed(GENERIC, "GEN", 8)], + )], + ®istry, + "es, + &options, + ) + .unwrap(); + + assert_eq!(markets.len(), 1); + } + + #[tokio::test] + async fn no_quote_marker_produces_no_markets() { + let client = client_with_tokens(serde_json::json!([token( + GENERIC, + "GEN", + serde_json::json!({}) + )])) + .await; + let tokens = client + .get_all_tokens() + .unwrap() + .into_values() + .collect::>(); + + assert!(quote_tokens(&tokens, &MarketListOptions::default()) + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn rejects_ambiguous_quote_tokens() { + let client = client_with_tokens(serde_json::json!([ + token(USDC, "USDC", serde_json::json!({ "marketQuote": true })), + token(GENERIC, "USDT", serde_json::json!({ "marketQuote": true })) + ])) + .await; + let tokens = client + .get_all_tokens() + .unwrap() + .into_values() + .collect::>(); + + let error = quote_tokens(&tokens, &MarketListOptions::default()) + .unwrap_err() + .to_string(); + assert!(error.contains("multiple tokens with marketQuote=true")); + } + + #[tokio::test] + async fn rejects_malformed_variant_addresses() { + let client = client_with_tokens(serde_json::json!([ + token(USDC, "USDC", serde_json::json!({ "marketQuote": true })), + token( + CANONICAL, + "WRAPPED", + serde_json::json!({ "unwrappedAddress": "not-an-address" }) + ) + ])) + .await; + let tokens = client + .get_all_tokens() + .unwrap() + .into_values() + .collect::>(); + + let error = registry_token_lookup(&tokens).unwrap_err().to_string(); + assert!(error.contains("invalid unwrappedAddress")); + } +} diff --git a/crates/common/src/raindex_client/markets/mod.rs b/crates/common/src/raindex_client/markets/mod.rs new file mode 100644 index 0000000000..059ed1ce89 --- /dev/null +++ b/crates/common/src/raindex_client/markets/mod.rs @@ -0,0 +1,597 @@ +#[cfg(not(target_family = "wasm"))] +use super::order_quotes::{get_order_quotes_batch_for_pairs, RaindexOrderQuote}; +use super::orders::{GetOrdersFilters, GetOrdersTokenFilter, RaindexOrder}; +#[cfg(not(target_family = "wasm"))] +use super::trades::{GetTradesFilters, GetTradesTokenFilter, RaindexTrade}; +use super::ChainIds; +#[cfg(not(target_family = "wasm"))] +use super::TimeFilter; +use super::{RaindexClient, RaindexError}; +use alloy::primitives::{Address, B256}; +#[cfg(not(target_family = "wasm"))] +use alloy::{primitives::U256, sol_types::SolValue}; +#[cfg(not(target_family = "wasm"))] +use chrono::Utc; +#[cfg(not(target_family = "wasm"))] +use futures::future::join_all; +#[cfg(not(target_family = "wasm"))] +use rain_erc::erc4626::{self, Erc4626BatchResponse, Erc4626BatchVault}; +#[cfg(not(target_family = "wasm"))] +use rain_math_float::Float; +use raindex_app_settings::token::TokenCfg; +#[cfg(not(target_family = "wasm"))] +use raindex_bindings::provider::mk_read_provider; +#[cfg(not(target_family = "wasm"))] +use raindex_bindings::IRaindexV6::OrderV4; +use serde_json::Value; +#[cfg(not(target_family = "wasm"))] +use std::cmp::Ordering; +use std::collections::{BTreeMap, HashMap, HashSet}; +#[cfg(not(target_family = "wasm"))] +use std::future::Future; +#[cfg(not(target_family = "wasm"))] +use std::ops::{Add, Div, Mul, Sub}; +use wasm_bindgen_utils::{prelude::*, wasm_export}; + +mod catalog; +#[cfg(not(target_family = "wasm"))] +mod orderbook; +#[cfg(not(target_family = "wasm"))] +mod trades; +mod types; + +use catalog::discover_markets; +#[cfg(not(target_family = "wasm"))] +use orderbook::{ + apply_book_errors, apply_book_levels, build_canonical_variant_map, build_variant_map, + fetch_book_levels, read_ratios, +}; +#[cfg(not(target_family = "wasm"))] +use trades::{apply_trades, fetch_normalized_trades}; +pub use types::*; + +const MARKET_QUOTE_EXTENSION: &str = "marketQuote"; +const MAX_QUERY_PAGES: u16 = 1_000; +const QUERY_PAGE_SIZE: u16 = 1_000; +#[cfg(not(target_family = "wasm"))] +const DEFAULT_ORDERBOOK_DEPTH: u16 = 100; +#[cfg(not(target_family = "wasm"))] +const DEFAULT_RECENT_TRADES_LIMIT: u16 = 20; +#[cfg(not(target_family = "wasm"))] +const TRADES_READ_TIMEOUT_MS: u64 = 8_000; +#[cfg(not(target_family = "wasm"))] +const RATIO_RPC_TIMEOUT_MS: u64 = 2_000; + +async fn fetch_market_orders_paginated( + client: &RaindexClient, + chain_ids: Vec, + filters: GetOrdersFilters, +) -> Result, RaindexError> { + let mut orders = Vec::new(); + let mut fetched = 0usize; + let mut total_count = 0usize; + + for page in 1..=MAX_QUERY_PAGES { + let result = client + .get_orders( + Some(ChainIds(chain_ids.clone())), + Some(filters.clone()), + Some(page), + Some(QUERY_PAGE_SIZE), + ) + .await?; + let page_orders = result.orders().to_vec(); + total_count = result.total_count() as usize; + if page_orders.is_empty() { + return Ok(orders); + } + fetched = fetched.saturating_add(page_orders.len()); + orders.extend(page_orders); + if fetched >= total_count { + return Ok(orders); + } + } + + Err(RaindexError::PreflightError(format!( + "order query exhausted the {MAX_QUERY_PAGES}-page safety limit with {fetched} of {total_count} rows" + ))) +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug, Clone, Copy)] +struct Variant { + canonical_address: Address, + price_multiplier: Float, +} + +#[cfg(not(target_family = "wasm"))] +type VariantBuild = (HashMap, Vec<(String, String)>); + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug, Clone)] +struct RatioValue { + asset_address: Address, + assets_per_share: Float, + formatted_assets_per_share: String, +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug, Clone, Default)] +struct RatioRead { + values: HashMap, + block_number: Option, +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug, Clone)] +struct BookLevel { + canonical_address: Address, + side: BookSide, + price: Float, + base_quantity: Float, + target_quantity: Float, + chain_id: u32, + raindex: Address, + order_hash: B256, + source_token: Address, + block_number: u64, +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug)] +struct BookRead { + levels: Vec, + errors: Vec<(Address, String)>, +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BookSide { + Bid, + Ask, +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Debug, Clone)] +struct NormalizedTrade { + canonical_address: Address, + trade: RaindexMarketTrade, + source_log_index: Option, + price: Float, + base_volume: Float, + target_volume: Float, +} + +#[cfg(not(target_family = "wasm"))] +#[derive(Clone, Copy)] +struct SnapshotReadOptions { + observed_at: u64, + orderbook_depth: usize, + recent_trades_limit: usize, + include_orderbook: bool, + include_ratios: bool, + include_trades: bool, +} + +#[wasm_export] +impl RaindexClient { + #[wasm_export( + js_name = "getMarkets", + return_description = "Quote-token markets discovered from active Raindex orders", + unchecked_return_type = "RaindexMarket[]" + )] + pub async fn get_markets( + &self, + options: Option, + ) -> Result, RaindexError> { + discover_markets(self, &options.unwrap_or_default()).await + } +} + +#[cfg(not(target_family = "wasm"))] +impl RaindexClient { + pub async fn get_market_snapshots( + &self, + options: Option, + ) -> Result, RaindexError> { + let options = options.unwrap_or_default(); + let markets = discover_markets(self, &options.markets).await?; + let observed_at = u64::try_from(Utc::now().timestamp()) + .map_err(|_| RaindexError::PreflightError("system time is before Unix epoch".into()))?; + let read_options = SnapshotReadOptions { + observed_at, + orderbook_depth: usize::from( + options.orderbook_depth.unwrap_or(DEFAULT_ORDERBOOK_DEPTH), + ), + recent_trades_limit: usize::from( + options + .recent_trades_limit + .unwrap_or(DEFAULT_RECENT_TRADES_LIMIT), + ), + include_orderbook: options.include_orderbook.unwrap_or(true), + include_ratios: options.include_ratios.unwrap_or(true), + include_trades: options.include_trades.unwrap_or(true), + }; + + let chain_ids = markets + .iter() + .map(|market| market.chain_id) + .collect::>(); + let chain_reads = chain_ids.into_iter().map(|chain_id| { + let chain_markets = markets + .iter() + .filter(|market| market.chain_id == chain_id) + .cloned() + .collect::>(); + let client = self.clone(); + async move { + let mut snapshots = chain_markets + .iter() + .cloned() + .map(|market| { + ( + market.id.clone(), + RaindexMarketSnapshot { + market, + orderbook: RaindexMarketOrderbook::default(), + stats: RaindexMarketStats::default(), + recent_trades: Vec::new(), + observed_at, + block_number: None, + ratio_block_number: None, + assets_per_share: None, + errors: Vec::new(), + }, + ) + }) + .collect::>(); + populate_chain_snapshots(&client, &chain_markets, &mut snapshots, &read_options) + .await; + snapshots + } + }); + let mut snapshots = join_all(chain_reads) + .await + .into_iter() + .flatten() + .collect::>(); + + Ok(markets + .iter() + .filter_map(|market| snapshots.remove(&market.id)) + .collect()) + } +} + +#[cfg(not(target_family = "wasm"))] +async fn populate_chain_snapshots( + client: &RaindexClient, + markets: &[RaindexMarket], + snapshots: &mut BTreeMap, + options: &SnapshotReadOptions, +) { + let Some(first_market) = markets.first() else { + return; + }; + let chain_id = first_market.chain_id; + let network = match client.get_network_by_chain_id(chain_id) { + Ok(network) => network, + Err(error) => { + push_chain_error(snapshots, markets, "registry", error.to_string()); + return; + } + }; + let ratios = if options.include_ratios { + let share_addresses = ratio_share_addresses(markets); + match read_ratios(&network.rpcs, share_addresses).await { + Ok(ratios) => ratios, + Err(error) => { + push_chain_error(snapshots, markets, "ratios", error); + RatioRead::default() + } + } + } else { + RatioRead::default() + }; + let (variant_map, variant_errors) = if options.include_ratios { + match build_variant_map(markets, &ratios.values) { + Ok(result) => result, + Err(error) => { + push_chain_error(snapshots, markets, "ratios", error.to_string()); + return; + } + } + } else { + match build_canonical_variant_map(markets) { + Ok(variants) => (variants, Vec::new()), + Err(error) => { + push_chain_error(snapshots, markets, "ratios", error.to_string()); + return; + } + } + }; + apply_variant_warnings(snapshots, variant_errors); + for market in markets { + if let Some(snapshot) = snapshots.get_mut(&market.id) { + snapshot.ratio_block_number = ratios.block_number; + snapshot.assets_per_share = ratios + .values + .get(&market.base.address) + .map(|ratio| ratio.formatted_assets_per_share.clone()); + } + } + + if options.include_trades { + let trades_client = client.clone(); + let trade_markets = markets.to_vec(); + let trade_variants = variant_map.clone(); + match with_market_timeout( + async move { + fetch_normalized_trades( + &trades_client, + &trade_markets, + &trade_variants, + options.observed_at, + ) + .await + }, + TRADES_READ_TIMEOUT_MS, + ) + .await + { + Some(Ok(trades)) => apply_trades( + snapshots, + markets, + trades.window, + trades.latest, + options.recent_trades_limit, + ), + Some(Err(error)) => push_chain_error(snapshots, markets, "trades", error.to_string()), + None => push_chain_error( + snapshots, + markets, + "trades", + format!("trade read timed out after {TRADES_READ_TIMEOUT_MS}ms"), + ), + } + } + + if options.include_orderbook { + match fetch_book_levels(client, markets, &variant_map).await { + Ok(book) => { + apply_book_levels(snapshots, markets, book.levels, options.orderbook_depth); + apply_book_errors(snapshots, markets, book.errors); + } + Err(error) => push_chain_error(snapshots, markets, "orderbook", error.to_string()), + } + } +} + +#[cfg(not(target_family = "wasm"))] +fn ratio_share_addresses(markets: &[RaindexMarket]) -> Vec
{ + markets + .iter() + .filter(|market| { + market.base.unwrapped_address.is_some() || market.base.legacy_address.is_some() + }) + .flat_map(|market| [Some(market.base.address), market.base.legacy_address]) + .flatten() + .collect::>() + .into_iter() + .collect() +} + +#[cfg(not(target_family = "wasm"))] +async fn with_market_timeout(future: F, timeout_ms: u64) -> Option +where + F: Future, +{ + tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), future) + .await + .ok() +} + +#[cfg(not(target_family = "wasm"))] +fn market_raindex_addresses(markets: &[RaindexMarket]) -> Vec
{ + let mut addresses = markets + .iter() + .flat_map(|market| market.raindex_addresses.iter().copied()) + .collect::>(); + addresses.sort_unstable(); + addresses.dedup(); + addresses +} + +#[cfg(not(target_family = "wasm"))] +fn push_chain_error( + snapshots: &mut BTreeMap, + markets: &[RaindexMarket], + source: &str, + message: String, +) { + markets.iter().for_each(|market| { + push_error(snapshots, &market.id, source, message.clone()); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn push_error( + snapshots: &mut BTreeMap, + market_id: &str, + source: &str, + message: String, +) { + push_issue( + snapshots, + market_id, + source, + RaindexMarketDataErrorSeverity::Error, + message, + ); +} + +#[cfg(not(target_family = "wasm"))] +fn push_warning( + snapshots: &mut BTreeMap, + market_id: &str, + source: &str, + message: String, +) { + push_issue( + snapshots, + market_id, + source, + RaindexMarketDataErrorSeverity::Warning, + message, + ); +} + +#[cfg(not(target_family = "wasm"))] +fn apply_variant_warnings( + snapshots: &mut BTreeMap, + warnings: Vec<(String, String)>, +) { + warnings.into_iter().for_each(|(market_id, message)| { + push_warning(snapshots, &market_id, "ratios", message); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn push_issue( + snapshots: &mut BTreeMap, + market_id: &str, + source: &str, + severity: RaindexMarketDataErrorSeverity, + message: String, +) { + if let Some(snapshot) = snapshots.get_mut(market_id) { + snapshot.errors.push(RaindexMarketDataError { + source: source.to_string(), + severity, + message, + }); + } +} + +#[cfg(not(target_family = "wasm"))] +fn float_cmp_asc(a: Float, b: Float) -> Ordering { + if a.lt(b).unwrap_or(false) { + Ordering::Less + } else if a.gt(b).unwrap_or(false) { + Ordering::Greater + } else { + Ordering::Equal + } +} + +#[cfg(not(target_family = "wasm"))] +fn float_cmp_desc(a: Float, b: Float) -> Ordering { + float_cmp_asc(b, a) +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + + fn market( + base: Address, + unwrapped_address: Option
, + legacy_address: Option
, + ) -> RaindexMarket { + let token = |address, symbol: &str| RaindexMarketToken { + chain_id: 8453, + address, + name: symbol.to_string(), + symbol: symbol.to_string(), + decimals: Some(18), + logo_uri: None, + extensions: None, + unwrapped_address: None, + legacy_address: None, + receipt_address: None, + variants: vec![address], + }; + let mut base_token = token(base, "BASE"); + base_token.unwrapped_address = unwrapped_address; + base_token.legacy_address = legacy_address; + RaindexMarket { + id: "market".into(), + ticker_id: "ticker".into(), + chain_id: 8453, + base: base_token, + quote: token(Address::repeat_byte(0x99), "QUOTE"), + raindex_addresses: vec![], + } + } + + fn snapshot(market: RaindexMarket) -> RaindexMarketSnapshot { + RaindexMarketSnapshot { + market, + orderbook: RaindexMarketOrderbook::default(), + stats: RaindexMarketStats::default(), + recent_trades: Vec::new(), + observed_at: 0, + block_number: None, + ratio_block_number: None, + assets_per_share: None, + errors: Vec::new(), + } + } + + #[test] + fn ratio_reads_exclude_ordinary_tokens() { + assert!( + ratio_share_addresses(&[market(Address::repeat_byte(0x11), None, None)]).is_empty() + ); + } + + #[test] + fn ratio_reads_include_only_declared_share_variants() { + let canonical = Address::repeat_byte(0x11); + let underlying = Address::repeat_byte(0x22); + let legacy = Address::repeat_byte(0x33); + let addresses = ratio_share_addresses(&[market(canonical, Some(underlying), Some(legacy))]); + + assert_eq!(addresses.len(), 2); + assert!(addresses.contains(&canonical)); + assert!(addresses.contains(&legacy)); + assert!(!addresses.contains(&underlying)); + } + + #[test] + fn unavailable_variant_ratios_are_nonfatal_warnings() { + let market = market( + Address::repeat_byte(0x11), + Some(Address::repeat_byte(0x22)), + Some(Address::repeat_byte(0x33)), + ); + let mut snapshots = BTreeMap::from([(market.id.clone(), snapshot(market.clone()))]); + + apply_variant_warnings( + &mut snapshots, + vec![( + market.id.clone(), + "legacy ERC4626 ratio is unavailable".into(), + )], + ); + + assert_eq!(snapshots[&market.id].errors.len(), 1); + assert_eq!( + snapshots[&market.id].errors[0].severity, + RaindexMarketDataErrorSeverity::Warning + ); + } + + #[tokio::test] + async fn market_timeout_returns_completed_work() { + assert_eq!(with_market_timeout(async { 42 }, 100).await, Some(42)); + } + + #[tokio::test] + async fn market_timeout_cancels_stalled_work() { + assert_eq!( + with_market_timeout(std::future::pending::<()>(), 1).await, + None + ); + } +} diff --git a/crates/common/src/raindex_client/markets/orderbook.rs b/crates/common/src/raindex_client/markets/orderbook.rs new file mode 100644 index 0000000000..baa78852f6 --- /dev/null +++ b/crates/common/src/raindex_client/markets/orderbook.rs @@ -0,0 +1,988 @@ +use super::*; + +struct PreparedOrder { + order: RaindexOrder, + decoded: OrderV4, + pairs: Vec, +} + +pub(super) async fn read_ratios( + rpcs: &[url::Url], + shares: Vec
, +) -> Result { + if shares.is_empty() { + return Ok(RatioRead::default()); + } + let vaults = shares + .into_iter() + .map(Erc4626BatchVault::new) + .collect::>(); + if rpcs.is_empty() { + return Err("no RPC URLs configured".to_string()); + } + let expected_ratio_count = vaults.len(); + let mut failures = Vec::new(); + let mut best_partial = None; + for rpc in rpcs { + let provider = match mk_read_provider(std::slice::from_ref(rpc)) { + Ok(provider) => provider, + Err(error) => { + failures.push(format!("{rpc}: {error}")); + continue; + } + }; + let ratio_vaults = vaults.clone(); + let response = with_market_timeout( + async move { erc4626::batch_share_ratios(&provider, ratio_vaults, None).await }, + RATIO_RPC_TIMEOUT_MS, + ) + .await; + match response { + Some(Ok(response)) => { + let read = ratio_read_from_response(response); + if read.values.len() == expected_ratio_count { + return Ok(read); + } + failures.push(format!( + "{rpc}: returned {} usable ratios out of {expected_ratio_count}", + read.values.len() + )); + if best_partial.as_ref().is_none_or(|best: &RatioRead| { + read.values.len() > best.values.len() + || (read.values.len() == best.values.len() + && read.block_number > best.block_number) + }) { + best_partial = Some(read); + } + } + Some(Err(error)) => failures.push(format!("{rpc}: {error}")), + None => failures.push(format!("{rpc}: timed out")), + } + } + if let Some(read) = best_partial { + return Ok(read); + } + Err(format!( + "all RPC ratio reads failed: {}", + failures.join("; ") + )) +} + +fn ratio_read_from_response(response: Erc4626BatchResponse) -> RatioRead { + let block_number = response.block_number; + let values = response + .items + .into_iter() + .filter_map(|item| { + item.data.and_then(|data| { + Float::parse(data.assets_display.clone()) + .ok() + .map(|assets_per_share| { + ( + item.vault_address, + RatioValue { + asset_address: data.asset_address, + assets_per_share, + formatted_assets_per_share: data.assets_display, + }, + ) + }) + }) + }) + .collect(); + RatioRead { + values, + block_number: Some(block_number), + } +} + +pub(super) fn build_variant_map( + markets: &[RaindexMarket], + ratios: &HashMap, +) -> Result { + let identity = Float::parse("1".to_string())?; + Ok(markets.iter().fold( + (HashMap::new(), Vec::new()), + |(mut variants, mut errors), market| { + variants.insert( + market.base.address, + Variant { + canonical_address: market.base.address, + price_multiplier: identity, + }, + ); + let canonical_ratio = ratios.get(&market.base.address).filter(|ratio| { + market + .base + .unwrapped_address + .is_none_or(|unwrapped| ratio.asset_address == unwrapped) + }); + + if let Some(unwrapped) = market.base.unwrapped_address { + match canonical_ratio { + Some(ratio) => { + variants.insert( + unwrapped, + Variant { + canonical_address: market.base.address, + price_multiplier: ratio.assets_per_share, + }, + ); + } + None => errors.push(( + market.id.clone(), + "canonical ERC4626 ratio is unavailable or has the wrong asset".into(), + )), + } + } + + if let Some(legacy) = market.base.legacy_address { + match (canonical_ratio, ratios.get(&legacy)) { + (Some(canonical), Some(legacy_ratio)) => { + match canonical + .assets_per_share + .div(legacy_ratio.assets_per_share) + { + Ok(price_multiplier) => { + variants.insert( + legacy, + Variant { + canonical_address: market.base.address, + price_multiplier, + }, + ); + } + Err(error) => errors.push((market.id.clone(), error.to_string())), + } + } + _ => errors.push(( + market.id.clone(), + "legacy ERC4626 ratio is unavailable".into(), + )), + } + } + (variants, errors) + }, + )) +} + +pub(super) fn build_canonical_variant_map( + markets: &[RaindexMarket], +) -> Result, RaindexError> { + let identity = Float::parse("1".to_string())?; + Ok(markets + .iter() + .map(|market| { + ( + market.base.address, + Variant { + canonical_address: market.base.address, + price_multiplier: identity, + }, + ) + }) + .collect()) +} + +pub(super) async fn fetch_book_levels( + client: &RaindexClient, + markets: &[RaindexMarket], + variant_map: &HashMap, +) -> Result { + let first_market = markets + .first() + .ok_or_else(|| RaindexError::PreflightError("market list is empty".into()))?; + let chain_id = first_market.chain_id; + let quote_token = first_market.quote.address; + let raindex_addresses = market_raindex_addresses(markets); + let filters = market_order_filters(quote_token, variant_map, &raindex_addresses); + let orders = fetch_orders(client, chain_id, &filters, quote_token, variant_map).await?; + let token_decimals = orders + .iter() + .flat_map(|prepared| { + prepared + .order + .input_vaults() + .iter() + .chain(prepared.order.output_vaults()) + }) + .map(|vault| (vault.token_address(), vault.token().decimals())) + .collect::>(); + + let quote_orders = orders + .iter() + .map(|prepared| prepared.order.clone()) + .collect::>(); + let selected_pairs = orders + .iter() + .map(|prepared| prepared.pairs.clone()) + .collect::>(); + let quote_results = + get_order_quotes_batch_for_pairs("e_orders, &selected_pairs, None, None).await?; + require_at_least_one_successful_quote("e_results)?; + let errors = orders + .iter() + .zip("e_results) + .flat_map(|(prepared, quotes)| { + quote_errors_from_order(prepared, quotes, quote_token, variant_map) + }) + .collect(); + let levels = orders + .iter() + .zip(quote_results) + .flat_map(|(prepared, quotes)| { + levels_from_order(prepared, "es, quote_token, variant_map, &token_decimals) + .unwrap_or_else(|error| { + tracing::warn!( + order_hash = %prepared.order.raw_order_hash(), + error = %error, + "excluding invalid order from market snapshot" + ); + Vec::new() + }) + }) + .collect(); + Ok(BookRead { levels, errors }) +} + +fn require_at_least_one_successful_quote( + quote_results: &[Vec], +) -> Result<(), RaindexError> { + let attempted = quote_results.iter().map(Vec::len).sum::(); + if attempted > 0 + && !quote_results + .iter() + .flatten() + .any(|quote| quote.success && quote.data.is_some()) + { + return Err(RaindexError::PreflightError(format!( + "all {attempted} order quote attempts failed" + ))); + } + Ok(()) +} + +fn market_order_filters( + quote_token: Address, + variant_map: &HashMap, + raindex_addresses: &[Address], +) -> [GetOrdersFilters; 2] { + let mut base_tokens = variant_map.keys().copied().collect::>(); + base_tokens.sort_unstable(); + base_tokens.dedup(); + [ + GetOrdersFilters { + active: Some(true), + tokens: Some(GetOrdersTokenFilter { + inputs: Some(vec![quote_token]), + outputs: Some(base_tokens.clone()), + }), + raindex_addresses: Some(raindex_addresses.to_vec()), + ..Default::default() + }, + GetOrdersFilters { + active: Some(true), + tokens: Some(GetOrdersTokenFilter { + inputs: Some(base_tokens), + outputs: Some(vec![quote_token]), + }), + raindex_addresses: Some(raindex_addresses.to_vec()), + ..Default::default() + }, + ] +} + +async fn fetch_orders( + client: &RaindexClient, + chain_id: u32, + filters: &[GetOrdersFilters], + quote_token: Address, + variant_map: &HashMap, +) -> Result, RaindexError> { + let mut seen = HashSet::new(); + let mut orders = Vec::new(); + for filter in filters { + let direction = + fetch_orders_for_filter(client, chain_id, filter, quote_token, variant_map).await?; + orders.extend(direction.into_iter().filter(|prepared| { + seen.insert(( + prepared.order.raw_raindex(), + prepared.order.raw_order_hash(), + )) + })); + } + Ok(orders) +} + +async fn fetch_orders_for_filter( + client: &RaindexClient, + chain_id: u32, + filter: &GetOrdersFilters, + quote_token: Address, + variant_map: &HashMap, +) -> Result, RaindexError> { + let mut orders = Vec::new(); + let mut seen = HashSet::new(); + for order in fetch_market_orders_paginated(client, vec![chain_id], filter.clone()).await? { + if seen.insert((order.raw_raindex(), order.raw_order_hash())) { + if let Some(order) = prepare_order(order, quote_token, variant_map) { + orders.push(order); + } + } + } + + Ok(orders) +} + +fn quote_errors_from_order( + prepared: &PreparedOrder, + quotes: &[RaindexOrderQuote], + quote_token: Address, + variant_map: &HashMap, +) -> Vec<(Address, String)> { + quotes + .iter() + .filter(|quote| !quote.success || quote.data.is_none()) + .filter_map(|quote| { + let input = prepared + .decoded + .validInputs + .get(quote.pair.input_index as usize)?; + let output = prepared + .decoded + .validOutputs + .get(quote.pair.output_index as usize)?; + let source_token = if input.token == quote_token { + output.token + } else if output.token == quote_token { + input.token + } else { + return None; + }; + let market = variant_map.get(&source_token)?.canonical_address; + let message = quote.error.clone().unwrap_or_else(|| { + format!( + "order {:#x} returned no quote data for IO pair {}/{}", + prepared.order.raw_order_hash(), + quote.pair.input_index, + quote.pair.output_index + ) + }); + Some((market, message)) + }) + .collect() +} + +fn prepare_order( + order: RaindexOrder, + quote_token: Address, + variant_map: &HashMap, +) -> Option { + let decoded = OrderV4::abi_decode(order.raw_order_bytes().as_ref()).ok()?; + let pairs = market_pairs_from_order(&decoded, quote_token, variant_map); + (!pairs.is_empty()).then_some(PreparedOrder { + order, + decoded, + pairs, + }) +} + +fn market_pairs_from_order( + decoded: &OrderV4, + quote_token: Address, + variant_map: &HashMap, +) -> Vec { + decoded + .validInputs + .iter() + .enumerate() + .flat_map(|(input_index, input)| { + decoded + .validOutputs + .iter() + .enumerate() + .filter(move |(_, output)| { + (input.token == quote_token && variant_map.contains_key(&output.token)) + || (output.token == quote_token && variant_map.contains_key(&input.token)) + }) + .map(move |(output_index, _)| raindex_quote::Pair { + pair_name: String::new(), + input_index: input_index as u32, + output_index: output_index as u32, + }) + }) + .collect() +} + +fn levels_from_order( + prepared: &PreparedOrder, + quotes: &[RaindexOrderQuote], + quote_token: Address, + variant_map: &HashMap, + token_decimals: &HashMap, +) -> Result, RaindexError> { + Ok(quotes + .iter() + .filter_map(|quote| { + let data = quote.success.then_some(quote.data.as_ref()).flatten()?; + let input = prepared + .decoded + .validInputs + .get(quote.pair.input_index as usize)?; + let output = prepared + .decoded + .validOutputs + .get(quote.pair.output_index as usize)?; + let (side, source_token, variant, price, base_quantity, target_quantity) = + if input.token == quote_token { + let variant = variant_map.get(&output.token)?; + ( + BookSide::Ask, + output.token, + *variant, + data.ratio.mul(variant.price_multiplier).ok()?, + data.max_output.div(variant.price_multiplier).ok()?, + data.max_input, + ) + } else if output.token == quote_token { + let variant = variant_map.get(&input.token)?; + ( + BookSide::Bid, + input.token, + *variant, + data.inverse_ratio.mul(variant.price_multiplier).ok()?, + data.max_input.div(variant.price_multiplier).ok()?, + data.max_output, + ) + } else { + return None; + }; + has_executable_atomic_amounts( + data.max_output, + data.ratio, + *token_decimals.get(&input.token)?, + *token_decimals.get(&output.token)?, + ) + .ok() + .filter(|executable| *executable)?; + Some(BookLevel { + canonical_address: variant.canonical_address, + side, + price, + base_quantity, + target_quantity, + chain_id: prepared.order.chain_id(), + raindex: prepared.order.raw_raindex(), + order_hash: prepared.order.raw_order_hash(), + source_token, + block_number: quote.block_number, + }) + }) + .collect()) +} + +pub(super) fn has_executable_atomic_amounts( + max_output: Float, + ratio: Float, + input_decimals: u8, + output_decimals: u8, +) -> Result { + let max_input = max_output.mul(ratio)?; + Ok( + max_input.to_fixed_decimal_lossy(input_decimals)?.0 != U256::ZERO + && max_output.to_fixed_decimal_lossy(output_decimals)?.0 != U256::ZERO, + ) +} + +pub(super) fn apply_book_levels( + snapshots: &mut BTreeMap, + markets: &[RaindexMarket], + levels: Vec, + depth: usize, +) { + for market in markets { + let mut bids = levels + .iter() + .filter(|level| { + level.canonical_address == market.base.address && level.side == BookSide::Bid + }) + .cloned() + .collect::>(); + let mut asks = levels + .iter() + .filter(|level| { + level.canonical_address == market.base.address && level.side == BookSide::Ask + }) + .cloned() + .collect::>(); + bids.sort_by(|a, b| float_cmp_desc(a.price, b.price)); + asks.sort_by(|a, b| float_cmp_asc(a.price, b.price)); + let per_side = depth.div_ceil(2); + bids.truncate(per_side); + asks.truncate(per_side); + + let best_bid = bids.first().map(|level| level.price); + let best_ask = asks.first().map(|level| level.price); + let best_price_comparison = best_bid.zip(best_ask).map(|(bid, ask)| bid.gt(ask)); + let midpoint = match (best_bid, best_ask, &best_price_comparison) { + (Some(bid), Some(ask), Some(Ok(false))) => Float::parse("2".into()) + .and_then(|two| bid.add(ask)?.div(two)) + .ok(), + _ => None, + }; + let block_number = bids + .iter() + .chain(&asks) + .map(|level| level.block_number) + .max(); + let book = RaindexMarketOrderbook { + best_bid: best_bid.and_then(|value| value.format().ok()), + best_ask: best_ask.and_then(|value| value.format().ok()), + midpoint: midpoint.and_then(|value| value.format().ok()), + bids: bids.into_iter().filter_map(format_level).collect(), + asks: asks.into_iter().filter_map(format_level).collect(), + }; + if let Some(snapshot) = snapshots.get_mut(&market.id) { + snapshot.orderbook = book; + snapshot.block_number = block_number; + match best_price_comparison { + Some(Ok(true)) => snapshot.errors.push(RaindexMarketDataError { + source: "orderbook".into(), + severity: RaindexMarketDataErrorSeverity::Error, + message: "best bid is greater than best ask".into(), + }), + Some(Err(error)) => snapshot.errors.push(RaindexMarketDataError { + source: "orderbook".into(), + severity: RaindexMarketDataErrorSeverity::Error, + message: format!("unable to compare best bid and best ask: {error}"), + }), + _ => {} + } + } + } +} + +pub(super) fn apply_book_errors( + snapshots: &mut BTreeMap, + markets: &[RaindexMarket], + errors: Vec<(Address, String)>, +) { + let mut seen = HashSet::new(); + errors + .into_iter() + .filter(|error| seen.insert(error.clone())) + .for_each(|(canonical_address, message)| { + markets + .iter() + .filter(|market| market.base.address == canonical_address) + .for_each(|market| push_warning(snapshots, &market.id, "quote", message.clone())); + }); +} + +fn format_level(level: BookLevel) -> Option { + Some(RaindexMarketOrderbookLevel { + price: level.price.format().ok()?, + base_quantity: level.base_quantity.format().ok()?, + target_quantity: level.target_quantity.format().ok()?, + chain_id: level.chain_id, + raindex: level.raindex, + order_hash: level.order_hash, + source_token: level.source_token, + block_number: level.block_number, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::raindex_client::order_quotes::RaindexOrderQuoteValue; + use alloy::primitives::address; + + const BASE: Address = address!("1111111111111111111111111111111111111111"); + const QUOTE: Address = address!("2222222222222222222222222222222222222222"); + const UNWRAPPED: Address = address!("3333333333333333333333333333333333333333"); + const LEGACY: Address = address!("4444444444444444444444444444444444444444"); + + fn float(value: &str) -> Float { + Float::parse(value.to_string()).unwrap() + } + + fn quote(success: bool) -> RaindexOrderQuote { + let ratio = float("1"); + RaindexOrderQuote { + pair: raindex_quote::Pair { + pair_name: String::new(), + input_index: 0, + output_index: 0, + }, + block_number: 1, + data: success.then_some(RaindexOrderQuoteValue { + max_output: ratio, + formatted_max_output: "1".into(), + max_input: ratio, + formatted_max_input: "1".into(), + ratio, + formatted_ratio: "1".into(), + inverse_ratio: ratio, + formatted_inverse_ratio: "1".into(), + formatted_max_output_as_percent_of_vault: None, + }), + success, + error: (!success).then(|| "quote failed".into()), + signed_context: Vec::new(), + } + } + + fn token(address: Address, symbol: &str) -> RaindexMarketToken { + RaindexMarketToken { + chain_id: 8453, + address, + name: symbol.to_string(), + symbol: symbol.to_string(), + decimals: Some(18), + logo_uri: None, + extensions: None, + unwrapped_address: None, + legacy_address: None, + receipt_address: None, + variants: vec![address], + } + } + + fn market() -> RaindexMarket { + RaindexMarket { + id: "8453:base:quote".to_string(), + ticker_id: "base_quote".to_string(), + chain_id: 8453, + base: token(BASE, "BASE"), + quote: token(QUOTE, "QUOTE"), + raindex_addresses: Vec::new(), + } + } + + fn snapshot(market: RaindexMarket) -> RaindexMarketSnapshot { + RaindexMarketSnapshot { + market, + orderbook: RaindexMarketOrderbook::default(), + stats: RaindexMarketStats::default(), + recent_trades: Vec::new(), + observed_at: 0, + block_number: None, + ratio_block_number: None, + assets_per_share: None, + errors: Vec::new(), + } + } + + fn level(side: BookSide, price: &str, block_number: u64) -> BookLevel { + BookLevel { + canonical_address: BASE, + side, + price: float(price), + base_quantity: float("2"), + target_quantity: float("20"), + chain_id: 8453, + raindex: Address::ZERO, + order_hash: B256::from([block_number as u8; 32]), + source_token: BASE, + block_number, + } + } + + #[tokio::test] + async fn ratio_read_skips_rpc_configuration_when_no_share_tokens_exist() { + let read = read_ratios(&[], Vec::new()).await.unwrap(); + + assert!(read.values.is_empty()); + assert_eq!(read.block_number, None); + } + + #[tokio::test] + async fn ratio_read_requires_an_rpc_for_share_tokens() { + let error = read_ratios(&[], vec![BASE]).await.unwrap_err(); + + assert_eq!(error, "no RPC URLs configured"); + } + + #[tokio::test] + async fn ratio_read_reports_provider_failures_for_every_rpc() { + let rpcs = [ + url::Url::parse("ftp://first.invalid").unwrap(), + url::Url::parse("file:///second.invalid").unwrap(), + ]; + + let error = read_ratios(&rpcs, vec![BASE]).await.unwrap_err(); + + assert!(error.starts_with("all RPC ratio reads failed:")); + assert!(error.contains("ftp://first.invalid")); + assert!(error.contains("file:///second.invalid")); + } + + #[test] + fn ratio_response_keeps_only_usable_items() { + use rain_erc::erc4626::{ + Erc4626BatchItem, Erc4626BatchResponse, Erc4626ShareAssetConversion, + }; + + let response = Erc4626BatchResponse { + block_number: 42, + block_timestamp: 0, + captured_at: 0, + items: vec![ + Erc4626BatchItem { + vault_address: BASE, + success: true, + data: Some(Erc4626ShareAssetConversion { + share_token_address: BASE, + share_token_decimals: 18, + asset_address: UNWRAPPED, + asset_decimals: 18, + shares: U256::from(1), + shares_display: "1".into(), + assets: U256::from(2), + assets_display: "2".into(), + }), + error: None, + }, + Erc4626BatchItem { + vault_address: LEGACY, + success: false, + data: None, + error: Some("unavailable".into()), + }, + ], + }; + + let read = ratio_read_from_response(response); + + assert_eq!(read.block_number, Some(42)); + assert_eq!(read.values.len(), 1); + assert!(read.values.contains_key(&BASE)); + assert!(!read.values.contains_key(&LEGACY)); + } + + #[test] + fn variant_map_normalizes_unwrapped_and_legacy_tokens_to_the_canonical_market() { + let mut configured_market = market(); + configured_market.base.unwrapped_address = Some(UNWRAPPED); + configured_market.base.legacy_address = Some(LEGACY); + let ratios = HashMap::from([ + ( + BASE, + RatioValue { + asset_address: UNWRAPPED, + assets_per_share: float("2"), + formatted_assets_per_share: "2".to_string(), + }, + ), + ( + LEGACY, + RatioValue { + asset_address: UNWRAPPED, + assets_per_share: float("4"), + formatted_assets_per_share: "4".to_string(), + }, + ), + ]); + + let (variants, errors) = build_variant_map(&[configured_market], &ratios).unwrap(); + + assert!(errors.is_empty()); + assert_eq!(variants[&BASE].canonical_address, BASE); + assert_eq!(variants[&UNWRAPPED].price_multiplier.format().unwrap(), "2"); + assert_eq!(variants[&LEGACY].price_multiplier.format().unwrap(), "0.5"); + } + + #[test] + fn variant_map_keeps_canonical_market_and_reports_missing_ratios() { + let mut configured_market = market(); + configured_market.base.unwrapped_address = Some(UNWRAPPED); + configured_market.base.legacy_address = Some(LEGACY); + + let (variants, errors) = build_variant_map(&[configured_market], &HashMap::new()).unwrap(); + + assert_eq!(variants.len(), 1); + assert_eq!(variants[&BASE].price_multiplier.format().unwrap(), "1"); + assert_eq!(errors.len(), 2); + assert!(errors[0].1.contains("canonical ERC4626 ratio")); + assert!(errors[1].1.contains("legacy ERC4626 ratio")); + } + + #[test] + fn canonical_variant_map_excludes_wrapper_variants() { + let mut configured_market = market(); + configured_market.base.unwrapped_address = Some(UNWRAPPED); + configured_market.base.legacy_address = Some(LEGACY); + + let variants = build_canonical_variant_map(&[configured_market]).unwrap(); + + assert_eq!(variants.len(), 1); + assert_eq!(variants[&BASE].canonical_address, BASE); + assert_eq!(variants[&BASE].price_multiplier.format().unwrap(), "1"); + assert!(!variants.contains_key(&UNWRAPPED)); + assert!(!variants.contains_key(&LEGACY)); + } + + #[test] + fn order_filters_query_only_quote_to_base_directions() { + let variants = HashMap::from([ + ( + BASE, + Variant { + canonical_address: BASE, + price_multiplier: float("1"), + }, + ), + ( + LEGACY, + Variant { + canonical_address: BASE, + price_multiplier: float("0.5"), + }, + ), + ]); + + let raindexes = [BASE]; + let [asks, bids] = market_order_filters(QUOTE, &variants, &raindexes); + let ask_tokens = asks.tokens.unwrap(); + let bid_tokens = bids.tokens.unwrap(); + + assert_eq!(ask_tokens.inputs, Some(vec![QUOTE])); + assert_eq!(ask_tokens.outputs, Some(vec![BASE, LEGACY])); + assert_eq!(bid_tokens.inputs, Some(vec![BASE, LEGACY])); + assert_eq!(bid_tokens.outputs, Some(vec![QUOTE])); + assert_eq!(asks.has_positive_output_vault_balance, None); + assert_eq!(bids.has_positive_output_vault_balance, None); + assert_eq!(asks.raindex_addresses, Some(raindexes.to_vec())); + assert_eq!(bids.raindex_addresses, Some(raindexes.to_vec())); + } + + #[test] + fn book_levels_are_sorted_truncated_and_summarized() { + let market = market(); + let mut snapshots = BTreeMap::from([(market.id.clone(), snapshot(market.clone()))]); + let levels = vec![ + level(BookSide::Bid, "8", 1), + level(BookSide::Bid, "10", 2), + level(BookSide::Bid, "9", 3), + level(BookSide::Ask, "13", 4), + level(BookSide::Ask, "11", 5), + level(BookSide::Ask, "12", 6), + ]; + + apply_book_levels(&mut snapshots, std::slice::from_ref(&market), levels, 4); + + let snapshot = &snapshots[&market.id]; + assert_eq!(snapshot.orderbook.best_bid.as_deref(), Some("10")); + assert_eq!(snapshot.orderbook.best_ask.as_deref(), Some("11")); + assert_eq!(snapshot.orderbook.midpoint.as_deref(), Some("10.5")); + assert_eq!( + snapshot + .orderbook + .bids + .iter() + .map(|level| level.price.as_str()) + .collect::>(), + vec!["10", "9"] + ); + assert_eq!( + snapshot + .orderbook + .asks + .iter() + .map(|level| level.price.as_str()) + .collect::>(), + vec!["11", "12"] + ); + assert_eq!(snapshot.block_number, Some(6)); + assert!(snapshot.errors.is_empty()); + } + + #[test] + fn one_level_depth_keeps_the_best_level_on_each_side() { + let market = market(); + let mut snapshots = BTreeMap::from([(market.id.clone(), snapshot(market.clone()))]); + + apply_book_levels( + &mut snapshots, + std::slice::from_ref(&market), + vec![ + level(BookSide::Bid, "10", 1), + level(BookSide::Bid, "9", 2), + level(BookSide::Ask, "11", 3), + level(BookSide::Ask, "12", 4), + ], + 1, + ); + + let snapshot = &snapshots[&market.id]; + assert_eq!(snapshot.orderbook.bids.len(), 1); + assert_eq!(snapshot.orderbook.asks.len(), 1); + assert_eq!(snapshot.orderbook.best_bid.as_deref(), Some("10")); + assert_eq!(snapshot.orderbook.best_ask.as_deref(), Some("11")); + } + + #[test] + fn crossed_book_is_exposed_with_an_explicit_error_and_no_midpoint() { + let market = market(); + let mut snapshots = BTreeMap::from([(market.id.clone(), snapshot(market.clone()))]); + + apply_book_levels( + &mut snapshots, + std::slice::from_ref(&market), + vec![level(BookSide::Bid, "12", 1), level(BookSide::Ask, "11", 2)], + 100, + ); + + let snapshot = &snapshots[&market.id]; + assert_eq!(snapshot.orderbook.midpoint, None); + assert_eq!(snapshot.errors.len(), 1); + assert_eq!(snapshot.errors[0].source, "orderbook"); + assert!(snapshot.errors[0].message.contains("best bid")); + } + + #[test] + fn quote_errors_are_deduplicated_and_scoped_to_the_matching_market() { + let market = market(); + let mut snapshots = BTreeMap::from([(market.id.clone(), snapshot(market.clone()))]); + + apply_book_errors( + &mut snapshots, + std::slice::from_ref(&market), + vec![ + (BASE, "quote failed".to_string()), + (BASE, "quote failed".to_string()), + (QUOTE, "unrelated".to_string()), + ], + ); + + assert_eq!(snapshots[&market.id].errors.len(), 1); + assert_eq!(snapshots[&market.id].errors[0].message, "quote failed"); + assert_eq!( + snapshots[&market.id].errors[0].severity, + RaindexMarketDataErrorSeverity::Warning + ); + } + + #[test] + fn atomic_liquidity_requires_nonzero_input_and_output_amounts() { + assert!(has_executable_atomic_amounts(float("1"), float("2"), 6, 18).unwrap()); + assert!(!has_executable_atomic_amounts(float("0.0000001"), float("1"), 6, 18).unwrap()); + assert!(has_executable_atomic_amounts(float("0.0000001"), float("1"), 18, 18).unwrap()); + } + + #[test] + fn partial_quote_batches_keep_successful_results() { + assert!(require_at_least_one_successful_quote(&[vec![quote(false), quote(true),]]).is_ok()); + } + + #[test] + fn fully_failed_quote_batches_remain_fatal() { + let error = require_at_least_one_successful_quote(&[vec![quote(false), quote(false)]]) + .expect_err("all quotes failed"); + + assert!(error + .to_string() + .contains("all 2 order quote attempts failed")); + } +} diff --git a/crates/common/src/raindex_client/markets/trades.rs b/crates/common/src/raindex_client/markets/trades.rs new file mode 100644 index 0000000000..c14d41994b --- /dev/null +++ b/crates/common/src/raindex_client/markets/trades.rs @@ -0,0 +1,565 @@ +use super::*; +use crate::types::VaultBalanceChangeKind; + +pub(super) struct NormalizedTradeRead { + pub window: Vec, + pub latest: Vec, +} + +pub(super) async fn fetch_normalized_trades( + client: &RaindexClient, + markets: &[RaindexMarket], + variant_map: &HashMap, + observed_at: u64, +) -> Result { + let first_market = markets + .first() + .ok_or_else(|| RaindexError::PreflightError("market list is empty".into()))?; + let mut tokens = variant_map.keys().copied().collect::>(); + tokens.sort_unstable(); + tokens.dedup(); + let raindex_addresses = market_raindex_addresses(markets); + let filters = market_trade_filters( + first_market.quote.address, + &tokens, + &raindex_addresses, + observed_at, + ); + let trade_reads = join_all( + filters + .iter() + .map(|filter| fetch_trades(client, first_market.chain_id, filter)), + ) + .await; + let mut trades = Vec::new(); + for read in trade_reads { + trades.extend(read?); + } + let window = normalize_trades(&trades, first_market.quote.address, variant_map); + let traded_canonical_addresses = window + .iter() + .map(|trade| trade.canonical_address) + .collect::>(); + let latest_source_tokens = variant_map + .iter() + .filter_map(|(source, variant)| { + (!traded_canonical_addresses.contains(&variant.canonical_address)).then_some(*source) + }) + .collect(); + let latest = normalize_trades( + &client + .get_latest_market_trades( + first_market.chain_id, + first_market.quote.address, + latest_source_tokens, + raindex_addresses, + ) + .await?, + first_market.quote.address, + variant_map, + ); + + Ok(NormalizedTradeRead { window, latest }) +} + +fn normalize_trades( + trades: &[RaindexTrade], + quote_token: Address, + variant_map: &HashMap, +) -> Vec { + collapse_clear_events( + trades + .iter() + .filter_map(|trade| match normalize_trade(trade, quote_token, variant_map) { + Ok(trade) => trade, + Err(error) => { + tracing::warn!(error = %error, "excluding invalid trade from market snapshot"); + None + } + }) + .collect(), + ) +} + +fn market_trade_filters( + quote_token: Address, + base_tokens: &[Address], + raindex_addresses: &[Address], + observed_at: u64, +) -> [GetTradesFilters; 2] { + let direction = |inputs, outputs| GetTradesFilters { + tokens: Some(GetTradesTokenFilter { + inputs: Some(inputs), + outputs: Some(outputs), + }), + raindex_addresses: Some(raindex_addresses.to_vec()), + time_filter: Some(TimeFilter { + start: Some(observed_at.saturating_sub(86_400)), + end: Some(observed_at), + }), + ..Default::default() + }; + [ + direction(vec![quote_token], base_tokens.to_vec()), + direction(base_tokens.to_vec(), vec![quote_token]), + ] +} + +async fn fetch_trades( + client: &RaindexClient, + chain_id: u32, + filters: &GetTradesFilters, +) -> Result, RaindexError> { + client + .get_trades_unpaginated(chain_id, filters.clone()) + .await +} + +pub(super) fn collapse_clear_events(trades: Vec) -> Vec { + trades + .into_iter() + .fold( + ( + Vec::::new(), + HashMap::<(Address, u32, Address, String), usize>::new(), + ), + |(mut collapsed, mut clear_indices), trade| { + if trade.trade.trade_event_kind.eq_ignore_ascii_case("clear") { + let key = ( + trade.canonical_address, + trade.trade.chain_id, + trade.trade.raindex, + trade.trade.trade_event_id.clone(), + ); + match clear_indices.get(&key).copied() { + Some(index) + if trade.trade.side == RaindexMarketTradeSide::Buy + && collapsed[index].trade.side != RaindexMarketTradeSide::Buy => + { + collapsed[index] = trade; + } + Some(_) => {} + None => { + clear_indices.insert(key, collapsed.len()); + collapsed.push(trade); + } + } + } else { + collapsed.push(trade); + } + (collapsed, clear_indices) + }, + ) + .0 +} + +fn normalize_trade( + trade: &RaindexTrade, + quote_token: Address, + variant_map: &HashMap, +) -> Result, RaindexError> { + let input_address = trade.raw_input_change().raw_token_address(); + let output_address = trade.raw_output_change().raw_token_address(); + let input_amount = trade.raw_input_change().raw_amount(); + let output_amount = Float::zero()?.sub(trade.raw_output_change().raw_amount())?; + let (variant, side, base_volume, target_volume, source_token) = if input_address == quote_token + { + let Some(variant) = variant_map.get(&output_address).copied() else { + return Ok(None); + }; + ( + variant, + RaindexMarketTradeSide::Buy, + output_amount.div(variant.price_multiplier)?, + input_amount, + output_address, + ) + } else if output_address == quote_token { + let Some(variant) = variant_map.get(&input_address).copied() else { + return Ok(None); + }; + ( + variant, + RaindexMarketTradeSide::Sell, + input_amount.div(variant.price_multiplier)?, + output_amount, + input_address, + ) + } else { + return Ok(None); + }; + if base_volume.is_zero()? || target_volume.is_zero()? { + return Ok(None); + } + let price = target_volume.div(base_volume)?; + let timestamp = u64::try_from(trade.raw_timestamp()) + .map_err(|_| RaindexError::PreflightError("trade timestamp does not fit u64".into()))?; + let block_number = u64::try_from(trade.raw_block_number()) + .map_err(|_| RaindexError::PreflightError("trade block number does not fit u64".into()))?; + Ok(Some(NormalizedTrade { + canonical_address: variant.canonical_address, + source_log_index: trade.raw_source_log_index(), + trade: RaindexMarketTrade { + trade_id: trade.raw_id().to_string(), + price: price.format()?, + base_volume: base_volume.format()?, + target_volume: target_volume.format()?, + timestamp, + block_number, + trade_event_id: trade.raw_trade_event_id().to_string(), + trade_event_kind: normalized_trade_event_kind(trade.raw_trade_event_kind()).to_string(), + side, + chain_id: trade.chain_id(), + raindex: trade.raw_raindex(), + order_hash: trade.raw_order_hash(), + source_token, + }, + price, + base_volume, + target_volume, + })) +} + +fn normalized_trade_event_kind(kind: &str) -> &'static str { + let subgraph_kind = VaultBalanceChangeKind::from_subgraph_typename(kind); + let normalized = if subgraph_kind == VaultBalanceChangeKind::Unknown { + VaultBalanceChangeKind::from_local_db_trade_kind(kind) + } else { + subgraph_kind + }; + match normalized { + VaultBalanceChangeKind::Clear => "clear", + VaultBalanceChangeKind::TakeOrder => "takeOrder", + _ => "unknown", + } +} + +pub(super) fn apply_trades( + snapshots: &mut BTreeMap, + markets: &[RaindexMarket], + trades: Vec, + latest_trades: Vec, + recent_limit: usize, +) { + for market in markets { + let mut market_trades = trades + .iter() + .filter(|trade| trade.canonical_address == market.base.address) + .cloned() + .collect::>(); + market_trades.sort_by(compare_trades_descending); + let latest_order_is_ambiguous = market_trades + .first() + .zip(market_trades.get(1)) + .is_some_and(|(first, second)| { + first.trade.block_number == second.trade.block_number + && (first.source_log_index.is_none() || second.source_log_index.is_none()) + }); + let latest_trade = market_trades.first().or_else(|| { + latest_trades + .iter() + .filter(|trade| trade.canonical_address == market.base.address) + .max_by(|a, b| compare_trades_descending(b, a)) + }); + let stats = match stats_from_trades(&market_trades, latest_trade) { + Ok(stats) => stats, + Err(error) => { + push_error(snapshots, &market.id, "trades", error.to_string()); + RaindexMarketStats::default() + } + }; + let recent_trades = market_trades + .into_iter() + .take(recent_limit) + .map(|trade| trade.trade) + .collect(); + if let Some(snapshot) = snapshots.get_mut(&market.id) { + snapshot.stats = stats; + snapshot.recent_trades = recent_trades; + if latest_order_is_ambiguous { + snapshot.errors.push(RaindexMarketDataError { + source: "trades".into(), + severity: RaindexMarketDataErrorSeverity::Warning, + message: "multiple executions share the latest indexed block; the source does not expose transaction/log ordering" + .into(), + }); + } + } + } +} + +fn compare_trades_descending(a: &NormalizedTrade, b: &NormalizedTrade) -> std::cmp::Ordering { + b.trade + .block_number + .cmp(&a.trade.block_number) + .then_with(|| match (b.source_log_index, a.source_log_index) { + (Some(b_index), Some(a_index)) => b_index.cmp(&a_index), + _ => b + .trade + .trade_event_id + .as_str() + .cmp(a.trade.trade_event_id.as_str()), + }) +} + +pub(super) fn stats_from_trades( + trades: &[NormalizedTrade], + latest_trade: Option<&NormalizedTrade>, +) -> Result { + let Some(first) = trades.first() else { + return Ok(RaindexMarketStats { + last_price: latest_trade.map(|trade| trade.price.format()).transpose()?, + ..Default::default() + }); + }; + let (high, low, base_volume, target_volume) = trades.iter().try_fold( + (first.price, first.price, Float::zero()?, Float::zero()?), + |(high, low, base, target), trade| { + Ok::<_, RaindexError>(( + if trade.price.gt(high)? { + trade.price + } else { + high + }, + if trade.price.lt(low)? { + trade.price + } else { + low + }, + base.add(trade.base_volume)?, + target.add(trade.target_volume)?, + )) + }, + )?; + Ok(RaindexMarketStats { + last_price: Some(latest_trade.unwrap_or(first).price.format()?), + high_24h: Some(high.format()?), + low_24h: Some(low.format()?), + base_volume_24h: base_volume.format()?, + target_volume_24h: target_volume.format()?, + trade_count_24h: trades.len() as u64, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + + const BASE: Address = address!("1111111111111111111111111111111111111111"); + const QUOTE: Address = address!("2222222222222222222222222222222222222222"); + const RAINDEX: Address = address!("3333333333333333333333333333333333333333"); + + fn normalized_trade(price: &str, base: &str, target: &str, block: u64) -> NormalizedTrade { + let canonical_address = address!("1111111111111111111111111111111111111111"); + NormalizedTrade { + canonical_address, + source_log_index: None, + trade: RaindexMarketTrade { + trade_id: format!("0x{block:064x}"), + price: price.to_string(), + base_volume: base.to_string(), + target_volume: target.to_string(), + timestamp: block, + block_number: block, + trade_event_id: format!("0x{block:064x}"), + trade_event_kind: "takeOrder".to_string(), + side: RaindexMarketTradeSide::Buy, + chain_id: 8453, + raindex: Address::ZERO, + order_hash: B256::ZERO, + source_token: canonical_address, + }, + price: Float::parse(price.to_string()).unwrap(), + base_volume: Float::parse(base.to_string()).unwrap(), + target_volume: Float::parse(target.to_string()).unwrap(), + } + } + + fn token(address: Address, symbol: &str) -> RaindexMarketToken { + RaindexMarketToken { + chain_id: 8453, + address, + name: symbol.to_string(), + symbol: symbol.to_string(), + decimals: Some(18), + logo_uri: None, + extensions: None, + unwrapped_address: None, + legacy_address: None, + receipt_address: None, + variants: vec![address], + } + } + + fn market() -> RaindexMarket { + RaindexMarket { + id: "8453:base:quote".to_string(), + ticker_id: "base_quote".to_string(), + chain_id: 8453, + base: token(BASE, "BASE"), + quote: token(QUOTE, "QUOTE"), + raindex_addresses: vec![RAINDEX], + } + } + + fn snapshot(market: RaindexMarket) -> RaindexMarketSnapshot { + RaindexMarketSnapshot { + market, + orderbook: RaindexMarketOrderbook::default(), + stats: RaindexMarketStats::default(), + recent_trades: Vec::new(), + observed_at: 0, + block_number: None, + ratio_block_number: None, + assets_per_share: None, + errors: Vec::new(), + } + } + + #[test] + fn stats_use_latest_trade_and_aggregate_exact_values() { + let trades = vec![ + normalized_trade("12", "2", "24", 3), + normalized_trade("10", "1.5", "15", 2), + normalized_trade("15", "1", "15", 1), + ]; + + let stats = stats_from_trades(&trades, trades.first()).unwrap(); + + assert_eq!(stats.last_price.as_deref(), Some("12")); + assert_eq!(stats.high_24h.as_deref(), Some("15")); + assert_eq!(stats.low_24h.as_deref(), Some("10")); + assert_eq!(stats.base_volume_24h, "4.5"); + assert_eq!(stats.target_volume_24h, "54"); + assert_eq!(stats.trade_count_24h, 3); + } + + #[test] + fn empty_stats_keep_market_visible_with_zero_volumes() { + assert_eq!( + stats_from_trades(&[], None).unwrap(), + RaindexMarketStats::default() + ); + } + + #[test] + fn historical_last_price_does_not_change_24_hour_aggregates() { + let historical = normalized_trade("9", "10", "90", 1); + + let stats = stats_from_trades(&[], Some(&historical)).unwrap(); + + assert_eq!(stats.last_price.as_deref(), Some("9")); + assert_eq!(stats.high_24h, None); + assert_eq!(stats.low_24h, None); + assert_eq!(stats.base_volume_24h, "0"); + assert_eq!(stats.target_volume_24h, "0"); + assert_eq!(stats.trade_count_24h, 0); + } + + #[test] + fn trade_event_kinds_preserve_unknown_values() { + assert_eq!(normalized_trade_event_kind("Clear"), "clear"); + assert_eq!(normalized_trade_event_kind("clear"), "clear"); + assert_eq!(normalized_trade_event_kind("TakeOrder"), "takeOrder"); + assert_eq!(normalized_trade_event_kind("take"), "takeOrder"); + assert_eq!(normalized_trade_event_kind("unexpected"), "unknown"); + } + + #[test] + fn same_block_trade_ordering_is_a_nonfatal_warning() { + let market = market(); + let mut second = normalized_trade("11", "1", "11", 3); + second.trade.trade_event_id = "0x02".to_string(); + let mut snapshots = BTreeMap::from([(market.id.clone(), snapshot(market.clone()))]); + + apply_trades( + &mut snapshots, + std::slice::from_ref(&market), + vec![normalized_trade("12", "2", "24", 3), second], + vec![], + 20, + ); + + let snapshot = &snapshots[&market.id]; + assert_eq!(snapshot.stats.trade_count_24h, 2); + assert_eq!(snapshot.errors.len(), 1); + assert_eq!( + snapshot.errors[0].severity, + RaindexMarketDataErrorSeverity::Warning + ); + } + + #[test] + fn same_block_local_trades_use_log_index_ordering() { + let market = market(); + let mut earlier = normalized_trade("11", "1", "11", 3); + earlier.source_log_index = Some(4); + earlier.trade.trade_event_id = "0xffff".to_string(); + let mut later = normalized_trade("12", "1", "12", 3); + later.source_log_index = Some(5); + later.trade.trade_event_id = "0x0000".to_string(); + let mut snapshots = BTreeMap::from([(market.id.clone(), snapshot(market.clone()))]); + + apply_trades( + &mut snapshots, + std::slice::from_ref(&market), + vec![earlier, later], + vec![], + 20, + ); + + let snapshot = &snapshots[&market.id]; + assert_eq!(snapshot.stats.last_price.as_deref(), Some("12")); + assert_eq!(snapshot.recent_trades[0].price, "12"); + assert!(snapshot.errors.is_empty()); + } + + #[test] + fn clear_sides_collapse_to_one_market_execution_and_prefer_buy() { + let mut buy = normalized_trade("12", "2", "24", 3); + buy.trade.trade_event_kind = "clear".to_string(); + let mut sell = buy.clone(); + sell.trade.side = RaindexMarketTradeSide::Sell; + sell.trade.trade_id = "0x02".to_string(); + + let collapsed = collapse_clear_events(vec![sell, buy]); + + assert_eq!(collapsed.len(), 1); + assert_eq!(collapsed[0].trade.side, RaindexMarketTradeSide::Buy); + assert_eq!( + stats_from_trades(&collapsed, collapsed.first()) + .unwrap() + .trade_count_24h, + 1 + ); + } + + #[test] + fn distinct_clear_event_ids_remain_distinct_executions() { + let mut first = normalized_trade("12", "2", "24", 3); + first.trade.trade_event_kind = "clear".to_string(); + let mut second = first.clone(); + second.trade.trade_event_id = "0x02".to_string(); + + let collapsed = collapse_clear_events(vec![first, second]); + + assert_eq!(collapsed.len(), 2); + } + + #[test] + fn trade_filters_pin_both_market_directions() { + let filters = market_trade_filters(QUOTE, &[BASE], &[RAINDEX], 100_000); + + let first = filters[0].tokens.as_ref().unwrap(); + assert_eq!(first.inputs, Some(vec![QUOTE])); + assert_eq!(first.outputs, Some(vec![BASE])); + let second = filters[1].tokens.as_ref().unwrap(); + assert_eq!(second.inputs, Some(vec![BASE])); + assert_eq!(second.outputs, Some(vec![QUOTE])); + for filter in filters { + assert_eq!(filter.raindex_addresses, Some(vec![RAINDEX])); + assert_eq!(filter.time_filter.unwrap().start, Some(13_600)); + } + } +} diff --git a/crates/common/src/raindex_client/markets/types.rs b/crates/common/src/raindex_client/markets/types.rs new file mode 100644 index 0000000000..c66bfd4040 --- /dev/null +++ b/crates/common/src/raindex_client/markets/types.rs @@ -0,0 +1,195 @@ +use super::*; +use serde::{Deserialize, Serialize}; +use wasm_bindgen_utils::{impl_wasm_traits, prelude::Tsify}; + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct MarketListOptions { + #[tsify(optional)] + pub chain_ids: Option>, + #[tsify(optional)] + pub ticker_ids: Option>, +} +impl_wasm_traits!(MarketListOptions); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct MarketSnapshotOptions { + #[serde(flatten)] + pub markets: MarketListOptions, + #[tsify(optional)] + pub orderbook_depth: Option, + #[tsify(optional)] + pub recent_trades_limit: Option, + #[tsify(optional)] + pub include_orderbook: Option, + #[tsify(optional)] + pub include_ratios: Option, + #[tsify(optional)] + pub include_trades: Option, +} +impl_wasm_traits!(MarketSnapshotOptions); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RaindexMarketToken { + pub chain_id: u32, + #[tsify(type = "Address")] + pub address: Address, + pub name: String, + pub symbol: String, + pub decimals: Option, + #[tsify(optional)] + pub logo_uri: Option, + #[tsify(optional, type = "Map")] + pub extensions: Option>, + #[tsify(optional, type = "Address")] + pub unwrapped_address: Option
, + #[tsify(optional, type = "Address")] + pub legacy_address: Option
, + #[tsify(optional, type = "Address")] + pub receipt_address: Option
, + #[tsify(type = "Address[]")] + pub variants: Vec
, +} +impl_wasm_traits!(RaindexMarketToken); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RaindexMarket { + pub id: String, + pub ticker_id: String, + pub chain_id: u32, + pub base: RaindexMarketToken, + pub quote: RaindexMarketToken, + #[tsify(type = "Address[]")] + pub raindex_addresses: Vec
, +} +impl_wasm_traits!(RaindexMarket); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RaindexMarketDataError { + pub source: String, + #[serde(default)] + pub severity: RaindexMarketDataErrorSeverity, + pub message: String, +} +impl_wasm_traits!(RaindexMarketDataError); + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Tsify, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum RaindexMarketDataErrorSeverity { + Warning, + #[default] + Error, +} +impl_wasm_traits!(RaindexMarketDataErrorSeverity); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RaindexMarketOrderbookLevel { + pub price: String, + pub base_quantity: String, + pub target_quantity: String, + pub chain_id: u32, + #[tsify(type = "Address")] + pub raindex: Address, + #[tsify(type = "Hex")] + pub order_hash: B256, + #[tsify(type = "Address")] + pub source_token: Address, + pub block_number: u64, +} +impl_wasm_traits!(RaindexMarketOrderbookLevel); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct RaindexMarketOrderbook { + #[tsify(optional)] + pub best_bid: Option, + #[tsify(optional)] + pub best_ask: Option, + #[tsify(optional)] + pub midpoint: Option, + pub bids: Vec, + pub asks: Vec, +} +impl_wasm_traits!(RaindexMarketOrderbook); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum RaindexMarketTradeSide { + Buy, + Sell, +} +impl_wasm_traits!(RaindexMarketTradeSide); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RaindexMarketTrade { + #[tsify(type = "Hex")] + pub trade_id: String, + pub price: String, + pub base_volume: String, + pub target_volume: String, + pub timestamp: u64, + pub block_number: u64, + #[tsify(type = "Hex")] + pub trade_event_id: String, + pub trade_event_kind: String, + pub side: RaindexMarketTradeSide, + pub chain_id: u32, + #[tsify(type = "Address")] + pub raindex: Address, + #[tsify(type = "Hex")] + pub order_hash: B256, + #[tsify(type = "Address")] + pub source_token: Address, +} +impl_wasm_traits!(RaindexMarketTrade); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RaindexMarketStats { + #[tsify(optional)] + pub last_price: Option, + #[tsify(optional)] + pub high_24h: Option, + #[tsify(optional)] + pub low_24h: Option, + pub base_volume_24h: String, + pub target_volume_24h: String, + pub trade_count_24h: u64, +} +impl Default for RaindexMarketStats { + fn default() -> Self { + Self { + last_price: None, + high_24h: None, + low_24h: None, + base_volume_24h: "0".to_string(), + target_volume_24h: "0".to_string(), + trade_count_24h: 0, + } + } +} +impl_wasm_traits!(RaindexMarketStats); + +#[derive(Debug, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RaindexMarketSnapshot { + pub market: RaindexMarket, + pub orderbook: RaindexMarketOrderbook, + pub stats: RaindexMarketStats, + pub recent_trades: Vec, + pub observed_at: u64, + #[tsify(optional)] + pub block_number: Option, + #[tsify(optional)] + pub ratio_block_number: Option, + #[tsify(optional)] + pub assets_per_share: Option, + pub errors: Vec, +} +impl_wasm_traits!(RaindexMarketSnapshot); diff --git a/crates/common/src/raindex_client/mod.rs b/crates/common/src/raindex_client/mod.rs index 200a2cc0b4..ef5aa25bd5 100644 --- a/crates/common/src/raindex_client/mod.rs +++ b/crates/common/src/raindex_client/mod.rs @@ -62,6 +62,7 @@ use wasm_bindgen_utils::{prelude::*, wasm_export}; pub mod add_orders; pub mod local_db; +pub mod markets; pub mod order_quotes; pub mod orders; pub mod orders_list; diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index eac9943ea7..c7cb3dfe8f 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -7,8 +7,8 @@ use alloy::primitives::{Address, B256}; use rain_math_float::Float; use raindex_bindings::IRaindexV6::{OrderV4, SignedContextV1, IOV2}; use raindex_quote::{ - get_order_quotes, BatchOrderQuotesResponse, NoopInjector, OrderQuoteValue, Pair, - SignedContextInjector, + get_order_quotes, get_order_quotes_for_pairs, BatchOrderQuotesResponse, NoopInjector, + OrderQuoteValue, Pair, SignedContextInjector, }; use raindex_subgraph_client::utils::float::{F0, F1}; use std::ops::{Div, Mul}; @@ -378,6 +378,24 @@ pub async fn get_order_quotes_batch( .await } +#[cfg(not(target_family = "wasm"))] +pub(crate) async fn get_order_quotes_batch_for_pairs( + orders: &[RaindexOrder], + selected_pairs: &[Vec], + block_number: Option, + chunk_size: Option, +) -> Result>, RaindexError> { + get_order_quotes_batch_inner( + orders, + Some(selected_pairs), + block_number, + chunk_size, + Address::ZERO, + &NoopInjector, + ) + .await +} + /// Batch variant of [`get_order_quotes_batch`] that threads a /// `counterparty` address and a caller-supplied [`SignedContextInjector`] /// through to the quote RPC. Oracle-fetched contexts come first, followed by @@ -391,6 +409,25 @@ pub async fn get_order_quotes_batch_with_injector( chunk_size: Option, counterparty: Address, injector: &dyn SignedContextInjector, +) -> Result>, RaindexError> { + get_order_quotes_batch_inner( + orders, + None, + block_number, + chunk_size, + counterparty, + injector, + ) + .await +} + +async fn get_order_quotes_batch_inner( + orders: &[RaindexOrder], + selected_pairs: Option<&[Vec]>, + block_number: Option, + chunk_size: Option, + counterparty: Address, + injector: &dyn SignedContextInjector, ) -> Result>, RaindexError> { let started_at = Timing::now(); if orders.is_empty() { @@ -429,14 +466,26 @@ pub async fn get_order_quotes_batch_with_injector( .map(|o| o.clone().into_sg_order()) .collect::, _>>()?; + if selected_pairs.is_some_and(|pairs| pairs.len() != orders.len()) { + return Err(RaindexError::PreflightError( + "Selected quote pairs must align with orders".into(), + )); + } let pair_counts: Vec = sg_orders .iter() - .map(|sg| { + .enumerate() + .map(|(order_index, sg)| { let order_v4: OrderV4 = sg.clone().try_into()?; let mut count = 0usize; - for input in &order_v4.validInputs { - for output in &order_v4.validOutputs { - if input.token != output.token { + for (input_index, input) in order_v4.validInputs.iter().enumerate() { + for (output_index, output) in order_v4.validOutputs.iter().enumerate() { + let is_selected = selected_pairs.is_none_or(|pairs| { + pairs[order_index].iter().any(|pair| { + pair.input_index == input_index as u32 + && pair.output_index == output_index as u32 + }) + }); + if input.token != output.token && is_selected { count += 1; } } @@ -456,20 +505,42 @@ pub async fn get_order_quotes_batch_with_injector( "starting order quote batch" ); - let flat_results = get_order_quotes( - sg_orders, - block_number, - rpcs, - chunk_size.map(|v| v as usize), - counterparty, - injector, - ) - .await?; + let flat_results = match selected_pairs { + Some(pairs) => { + get_order_quotes_for_pairs( + sg_orders, + pairs, + block_number, + rpcs, + chunk_size.map(|v| v as usize), + counterparty, + injector, + ) + .await? + } + None => { + get_order_quotes( + sg_orders, + block_number, + rpcs, + chunk_size.map(|v| v as usize), + counterparty, + injector, + ) + .await? + } + }; let flat_raindex: Vec = flat_results .into_iter() .map(RaindexOrderQuote::try_from_batch_order_quotes_response) .collect::, _>>()?; + if flat_raindex.len() != total_pair_count { + return Err(RaindexError::PreflightError(format!( + "Quote response count mismatch: expected {total_pair_count}, got {}", + flat_raindex.len() + ))); + } let successful_quote_count = flat_raindex.iter().filter(|quote| quote.success).count(); let failed_quote_count = flat_raindex.len().saturating_sub(successful_quote_count); for quote in flat_raindex.iter().filter(|quote| !quote.success) { diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index d16ecde2df..cafea77881 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -462,12 +462,30 @@ impl RaindexOrder { } } impl RaindexOrder { + pub(crate) fn input_vaults(&self) -> &[RaindexVault] { + &self.inputs + } + /// The order's output vaults, in the same order as the subgraph returned /// them. Unlike [`Self::outputs_list`] this is not filtered by vault type, /// so vaults that act as both input and output are still included. pub(crate) fn output_vaults(&self) -> &[RaindexVault] { &self.outputs } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn raw_order_bytes(&self) -> &Bytes { + &self.order_bytes + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn raw_order_hash(&self) -> B256 { + self.order_hash + } + + pub(crate) fn raw_raindex(&self) -> Address { + self.raindex + } } #[cfg(not(target_family = "wasm"))] impl RaindexOrder { @@ -3563,6 +3581,8 @@ mod tests { "id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "timestamp": "1632000000", "tradeEvent": { + "id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "__typename": "TakeOrder", "sender": "0x0000000000000000000000000000000000000000", "transaction": { "id": "0x0000000000000000000000000000000000000000000000000000000000000000", diff --git a/crates/common/src/raindex_client/trades/get_all.rs b/crates/common/src/raindex_client/trades/get_all.rs index 5ca92bf0be..37c304fb11 100644 --- a/crates/common/src/raindex_client/trades/get_all.rs +++ b/crates/common/src/raindex_client/trades/get_all.rs @@ -1,14 +1,18 @@ use super::*; +use crate::local_db::query::fetch_latest_trades_per_token::FetchLatestTradesPerTokenArgs; use crate::local_db::query::fetch_trades::{ extract_trades_count, FetchTradesArgs, FetchTradesTokensFilter, }; +use crate::raindex_client::local_db::query::fetch_latest_trades_per_token::fetch_latest_trades_per_token; use crate::raindex_client::local_db::query::fetch_trades::{fetch_trades, fetch_trades_count}; use crate::utils::timing::Timing; +use futures::future::join_all; use raindex_subgraph_client::types::common::{ - SgBigInt, SgBytes, SgTrade, SgTradeEventFilter, SgTradeOrderFilter, SgTradesListQueryFilters, + SgBigInt, SgBytes, SgTrade, SgTradeEventFilter, SgTradeOrderFilter, + SgTradeVaultBalanceChangeTokenFilter, SgTradeVaultTokenFilter, SgTradesListQueryFilters, SgTradesTokensFilterArgs, }; -use raindex_subgraph_client::MultiRaindexSubgraphClient; +use raindex_subgraph_client::{MultiRaindexSubgraphClient, SgPaginationArgs}; use tracing::{debug, error, info, info_span, Instrument}; use tsify::Tsify; use wasm_bindgen_utils::impl_wasm_traits; @@ -111,6 +115,36 @@ impl From for SgTradesListQueryFilters { }); } + if let Some(tokens) = filters.tokens { + let tokens: Option = tokens.into(); + let tokens = normalize_trade_tokens(tokens.unwrap_or_default()); + let vault_filter = |tokens: Vec| SgTradeVaultBalanceChangeTokenFilter { + vault_: Some(SgTradeVaultTokenFilter { token_in: tokens }), + }; + if !tokens.inputs.is_empty() + && !tokens.outputs.is_empty() + && tokens.inputs == tokens.outputs + { + sg_filters.or = Some(vec![ + SgTradesListQueryFilters { + input_vault_balance_change_: Some(vault_filter(tokens.inputs)), + ..Default::default() + }, + SgTradesListQueryFilters { + output_vault_balance_change_: Some(vault_filter(tokens.outputs)), + ..Default::default() + }, + ]); + } else { + if !tokens.inputs.is_empty() { + sg_filters.input_vault_balance_change_ = Some(vault_filter(tokens.inputs)); + } + if !tokens.outputs.is_empty() { + sg_filters.output_vault_balance_change_ = Some(vault_filter(tokens.outputs)); + } + } + } + sg_filters } } @@ -164,6 +198,34 @@ fn summarize_trade_filters(filters: &GetTradesFilters) -> TradeFilterTraceSummar } } +fn latest_market_trade_filter( + quote_token: Address, + base_token: Address, + raindex_addresses: &[Address], +) -> SgTradesListQueryFilters { + let vault_filter = |token: Address| SgTradeVaultBalanceChangeTokenFilter { + vault_: Some(SgTradeVaultTokenFilter { + token_in: vec![token.to_string().to_ascii_lowercase()], + }), + }; + let direction = |input: Address, output: Address| SgTradesListQueryFilters { + input_vault_balance_change_: Some(vault_filter(input)), + output_vault_balance_change_: Some(vault_filter(output)), + ..Default::default() + }; + SgTradesListQueryFilters { + raindex_in: raindex_addresses + .iter() + .map(|address| address.to_string().to_ascii_lowercase()) + .collect(), + or: Some(vec![ + direction(quote_token, base_token), + direction(base_token, quote_token), + ]), + ..Default::default() + } +} + pub(super) fn normalize_trade_tokens( mut tokens: SgTradesTokensFilterArgs, ) -> SgTradesTokensFilterArgs { @@ -527,6 +589,167 @@ impl RaindexClient { } } +#[cfg(not(target_family = "wasm"))] +impl RaindexClient { + /// Fetches every trade matching a filter in one source read. + /// + /// This is intended for bounded aggregate queries, such as a 24-hour market + /// window. It deliberately has no public WASM binding because interactive + /// callers should use the paginated `get_trades` API. + pub(crate) async fn get_trades_unpaginated( + &self, + chain_id: u32, + filters: GetTradesFilters, + ) -> Result, RaindexError> { + match self.query_source(chain_id) { + QuerySource::LocalDb(db) => { + let tokens = filters + .tokens + .clone() + .map(|tokens| FetchTradesTokensFilter { + inputs: tokens.inputs.unwrap_or_default(), + outputs: tokens.outputs.unwrap_or_default(), + }) + .unwrap_or_default(); + fetch_trades( + &db, + FetchTradesArgs { + chain_ids: vec![chain_id], + raindex_addresses: filters.raindex_addresses.unwrap_or_default(), + owners: filters.owners, + takers: filters.takers, + order_hash: filters.order_hash, + order_hashes: Vec::new(), + tokens, + time_filter: filters.time_filter.unwrap_or_default(), + pagination: PaginationParams::default(), + }, + ) + .await? + .into_iter() + .map(RaindexTrade::try_from_local_db_trade) + .collect() + } + QuerySource::Subgraph => { + let multi_subgraph_args = self.get_multi_subgraph_args(Some(vec![chain_id]))?; + let token_filter = filters + .tokens + .clone() + .and_then(Option::::from) + .map(normalize_trade_tokens); + 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 = MultiRaindexSubgraphClient::new( + multi_subgraph_args.values().flatten().cloned().collect(), + ); + let mut trades = client + .trades_list_all(filters.into()) + .await? + .into_iter() + .filter(|trade| { + token_filter.as_ref().is_none_or(|tokens| { + sg_trade_matches_token_filter(&trade.trade, tokens) + }) + }) + .map(|trade| { + let resolved_chain_id = name_to_chain_id + .get(trade.subgraph_name.as_str()) + .copied() + .ok_or_else(|| { + RaindexError::SubgraphNotFound( + trade.subgraph_name.clone(), + trade.trade.id.0.clone(), + ) + })?; + RaindexTrade::try_from_sg_trade(resolved_chain_id, trade.trade) + }) + .collect::, RaindexError>>()?; + trades.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| a.id.cmp(&b.id))); + Ok(trades) + } + } + } + + /// Returns at most one latest trade for every requested source token. + /// + /// The local DB performs this as one window-function query. The subgraph + /// fallback issues bounded, concurrent `first: 1` queries so historical + /// last prices never require downloading complete trade histories. + pub(crate) async fn get_latest_market_trades( + &self, + chain_id: u32, + quote_token: Address, + mut base_tokens: Vec
, + raindex_addresses: Vec
, + ) -> Result, RaindexError> { + base_tokens.sort_unstable(); + base_tokens.dedup(); + if base_tokens.is_empty() { + return Ok(Vec::new()); + } + + match self.query_source(chain_id) { + QuerySource::LocalDb(db) => fetch_latest_trades_per_token( + &db, + FetchLatestTradesPerTokenArgs { + chain_id, + raindex_addresses, + quote_token, + base_tokens, + }, + ) + .await? + .into_iter() + .map(RaindexTrade::try_from_local_db_trade) + .collect(), + QuerySource::Subgraph => { + let multi_subgraph_args = self.get_multi_subgraph_args(Some(vec![chain_id]))?; + 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 = MultiRaindexSubgraphClient::new( + multi_subgraph_args.values().flatten().cloned().collect(), + ); + let queries = base_tokens.into_iter().map(|base_token| { + client.trades_list( + latest_market_trade_filter(quote_token, base_token, &raindex_addresses), + SgPaginationArgs { + page: 1, + page_size: 1, + }, + ) + }); + let mut trades = Vec::new(); + for result in join_all(queries).await { + trades.extend(result?); + } + trades + .into_iter() + .map(|trade_with_name| { + let resolved_chain_id = name_to_chain_id + .get(trade_with_name.subgraph_name.as_str()) + .copied() + .ok_or_else(|| { + RaindexError::SubgraphNotFound( + trade_with_name.subgraph_name.clone(), + trade_with_name.trade.id.0.clone(), + ) + })?; + RaindexTrade::try_from_sg_trade(resolved_chain_id, trade_with_name.trade) + }) + .collect() + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -543,6 +766,103 @@ mod tests { use serde_json::json; #[cfg(not(target_family = "wasm"))] use tracing_test::traced_test; + #[cfg(not(target_family = "wasm"))] + use { + crate::local_db::query::{ + FromDbJson, LocalDbQueryError, LocalDbQueryExecutor, SqlStatement, SqlStatementBatch, + }, + crate::raindex_client::{local_db::LocalDb, tests::new_with_local_db}, + async_trait::async_trait, + std::sync::{Arc, Mutex}, + }; + + #[test] + fn latest_market_filter_matches_both_trade_directions() { + let quote = address!("2222222222222222222222222222222222222222"); + let base = address!("1111111111111111111111111111111111111111"); + let raindex = address!("3333333333333333333333333333333333333333"); + + let filter = latest_market_trade_filter(quote, base, &[raindex]); + + assert_eq!(filter.raindex_in, vec![raindex.to_string()]); + let directions = filter.or.expect("directional pair filters"); + assert_eq!(directions.len(), 2); + let token = |direction: &SgTradesListQueryFilters, input: bool| { + let change = if input { + direction.input_vault_balance_change_.as_ref() + } else { + direction.output_vault_balance_change_.as_ref() + } + .expect("token change filter"); + change + .vault_ + .as_ref() + .expect("vault filter") + .token_in + .first() + .expect("token") + .clone() + }; + assert_eq!(token(&directions[0], true), quote.to_string()); + assert_eq!(token(&directions[0], false), base.to_string()); + assert_eq!(token(&directions[1], true), base.to_string()); + assert_eq!(token(&directions[1], false), quote.to_string()); + } + + #[cfg(not(target_family = "wasm"))] + #[derive(Clone)] + struct EmptyLocalDb; + + #[cfg(not(target_family = "wasm"))] + #[async_trait] + impl LocalDbQueryExecutor for EmptyLocalDb { + async fn execute_batch(&self, _batch: &SqlStatementBatch) -> Result<(), LocalDbQueryError> { + Ok(()) + } + + async fn query_json(&self, _stmt: &SqlStatement) -> Result + where + T: FromDbJson, + { + serde_json::from_str("[]") + .map_err(|error| LocalDbQueryError::deserialization(error.to_string())) + } + + async fn query_text(&self, _stmt: &SqlStatement) -> Result { + Err(LocalDbQueryError::not_implemented("query_text")) + } + + async fn wipe_and_recreate(&self) -> Result<(), LocalDbQueryError> { + Err(LocalDbQueryError::not_implemented("wipe_and_recreate")) + } + } + + #[cfg(not(target_family = "wasm"))] + #[derive(Clone)] + struct UnexpectedLocalDb; + + #[cfg(not(target_family = "wasm"))] + #[async_trait] + impl LocalDbQueryExecutor for UnexpectedLocalDb { + async fn execute_batch(&self, _batch: &SqlStatementBatch) -> Result<(), LocalDbQueryError> { + Err(LocalDbQueryError::database("unexpected local DB write")) + } + + async fn query_json(&self, _stmt: &SqlStatement) -> Result + where + T: FromDbJson, + { + Err(LocalDbQueryError::database("unexpected local DB read")) + } + + async fn query_text(&self, _stmt: &SqlStatement) -> Result { + Err(LocalDbQueryError::database("unexpected local DB read")) + } + + async fn wipe_and_recreate(&self) -> Result<(), LocalDbQueryError> { + Err(LocalDbQueryError::database("unexpected local DB write")) + } + } #[test] fn trade_filter_trace_summary_counts_optional_filters() { @@ -650,7 +970,7 @@ mod tests { } #[test] - fn maps_token_filters_to_subgraph_candidate_filter_without_unsupported_child_nesting() { + fn maps_equal_token_filters_to_subgraph_direction_union() { let owner = address!("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); let raindex = address!("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); let token_a = address!("0x1111111111111111111111111111111111111111"); @@ -675,10 +995,67 @@ mod tests { assert_eq!(filters.timestamp_gte, Some(SgBigInt("10".to_string()))); assert_eq!(filters.timestamp_lte, Some(SgBigInt("20".to_string()))); assert_eq!(filters.raindex_in, vec![format!("{raindex:#x}")]); - assert!(filters.or.is_none()); + let directions = filters.or.as_ref().expect("token direction union"); + assert_eq!(directions.len(), 2); assert!(filters.input_vault_balance_change_.is_none()); assert!(filters.output_vault_balance_change_.is_none()); assert!(filters.trade_event_.is_none()); + assert_eq!( + directions[0] + .input_vault_balance_change_ + .as_ref() + .unwrap() + .vault_ + .as_ref() + .unwrap() + .token_in, + vec![token_a.to_string(), token_b.to_string()] + ); + assert_eq!( + directions[1] + .output_vault_balance_change_ + .as_ref() + .unwrap() + .vault_ + .as_ref() + .unwrap() + .token_in, + vec![token_a.to_string(), token_b.to_string()] + ); + } + + #[test] + fn maps_directional_token_filters_to_subgraph_intersection() { + let input = address!("0x1111111111111111111111111111111111111111"); + let output = address!("0x2222222222222222222222222222222222222222"); + let filters: SgTradesListQueryFilters = GetTradesFilters { + tokens: Some(GetTradesTokenFilter { + inputs: Some(vec![input]), + outputs: Some(vec![output]), + }), + ..Default::default() + } + .into(); + + assert!(filters.or.is_none()); + assert_eq!( + filters + .input_vault_balance_change_ + .unwrap() + .vault_ + .unwrap() + .token_in, + vec![input.to_string()] + ); + assert_eq!( + filters + .output_vault_balance_change_ + .unwrap() + .vault_ + .unwrap() + .token_in, + vec![output.to_string()] + ); } #[cfg(not(target_family = "wasm"))] @@ -721,6 +1098,69 @@ mod tests { assert!(logs_contain("completed get_trades")); } + #[cfg(not(target_family = "wasm"))] + #[tokio::test] + async fn unpaginated_trades_use_ready_local_db_without_subgraph_fallback() { + let sg_server = MockServer::start_async().await; + let subgraph = sg_server.mock(|when, then| { + when.path("/sg"); + then.status(500); + }); + let client = new_with_local_db( + vec![get_test_yaml( + &sg_server.url("/sg"), + &sg_server.url("/sg"), + "http://localhost:3000", + "http://localhost:3000", + )], + LocalDb::new(EmptyLocalDb), + vec![1], + ) + .await; + + let trades = client + .get_trades_unpaginated(1, GetTradesFilters::default()) + .await + .unwrap(); + + assert!(trades.is_empty()); + subgraph.assert_hits(0); + } + + #[cfg(not(target_family = "wasm"))] + #[tokio::test] + async fn unpaginated_trades_use_subgraph_when_local_db_is_not_ready() { + let sg_server = MockServer::start_async().await; + let subgraph = sg_server.mock(|when, then| { + when.path("/sg"); + then.status(200).json_body_obj(&json!({ + "data": { "trades": [] } + })); + }); + let mut client = RaindexClient::new( + vec![get_test_yaml( + &sg_server.url("/sg"), + &sg_server.url("/sg"), + "http://localhost:3000", + "http://localhost:3000", + )], + None, + None, + ) + .await + .unwrap(); + client.local_db_state.db = Arc::new(Mutex::new(Some(LocalDb::new(UnexpectedLocalDb)))); + client.local_db_state.sync_configured_chains.insert(1); + + let trades = client + .get_trades_unpaginated(1, GetTradesFilters::default()) + .await + .unwrap(); + + assert!(trades.is_empty()); + subgraph.assert_hits(1); + } + fn test_sg_trade(input_token: Address, output_token: Address) -> SgTrade { fn erc20(token: Address) -> SgErc20 { SgErc20 { @@ -770,6 +1210,11 @@ mod tests { "0x0000000000000000000000000000000000000000000000000000000000000001".to_string(), ), trade_event: SgTradeEvent { + id: SgBytes( + "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + ), + __typename: "TakeOrder".to_string(), transaction: SgTransaction { id: SgBytes( "0x0000000000000000000000000000000000000000000000000000000000000001" diff --git a/crates/common/src/raindex_client/trades/mod.rs b/crates/common/src/raindex_client/trades/mod.rs index e91e72173c..b0617dcc70 100644 --- a/crates/common/src/raindex_client/trades/mod.rs +++ b/crates/common/src/raindex_client/trades/mod.rs @@ -33,6 +33,9 @@ use wasm_bindgen_utils::prelude::js_sys::BigInt; #[wasm_bindgen] pub struct RaindexTrade { id: Bytes, + trade_event_id: Bytes, + trade_event_kind: String, + block_number: U256, chain_id: u32, raindex: Address, order_hash: B256, @@ -43,6 +46,51 @@ pub struct RaindexTrade { timestamp: U256, io_ratio: Float, formatted_io_ratio: String, + #[serde(skip)] + source_log_index: Option, +} + +#[cfg(not(target_family = "wasm"))] +impl RaindexTrade { + pub(crate) fn raw_id(&self) -> &Bytes { + &self.id + } + + pub(crate) fn raw_raindex(&self) -> Address { + self.raindex + } + + pub(crate) fn raw_trade_event_id(&self) -> &Bytes { + &self.trade_event_id + } + + pub(crate) fn raw_trade_event_kind(&self) -> &str { + &self.trade_event_kind + } + + pub(crate) fn raw_block_number(&self) -> U256 { + self.block_number + } + + pub(crate) fn raw_order_hash(&self) -> B256 { + self.order_hash + } + + pub(crate) fn raw_timestamp(&self) -> U256 { + self.timestamp + } + + pub(crate) fn raw_source_log_index(&self) -> Option { + self.source_log_index + } + + pub(crate) fn raw_input_change(&self) -> &RaindexVaultBalanceChange { + &self.input_vault_balance_change + } + + pub(crate) fn raw_output_change(&self) -> &RaindexVaultBalanceChange { + &self.output_vault_balance_change + } } #[cfg(target_family = "wasm")] #[wasm_bindgen] @@ -303,6 +351,9 @@ impl RaindexTrade { } pub fn try_from_sg_trade(chain_id: u32, trade: SgTrade) -> Result { + let trade_event_id = Bytes::from_str(&trade.trade_event.id.0)?; + let trade_event_kind = trade.trade_event.__typename.clone(); + let block_number = U256::from_str(&trade.trade_event.transaction.block_number.0)?; let input_vault_balance_change = RaindexVaultBalanceChange::try_from_sg_trade_balance_change( chain_id, @@ -321,6 +372,9 @@ impl RaindexTrade { Ok(RaindexTrade { id: Bytes::from_str(&trade.id.0)?, + trade_event_id, + trade_event_kind, + block_number, chain_id, raindex: Address::from_str(&trade.raindex.id.0)?, order_hash: B256::from_str(&trade.order.order_hash.0)?, @@ -331,11 +385,17 @@ impl RaindexTrade { timestamp: U256::from_str(&trade.timestamp.0)?, io_ratio, formatted_io_ratio, + source_log_index: None, }) } pub(crate) fn try_from_local_db_trade(trade: LocalDbOrderTrade) -> Result { let chain_id = trade.chain_id; + let trade_event_id = Bytes::from_str(&format!( + "0x{}{:016x}", + format!("{:#x}", trade.transaction_hash).trim_start_matches("0x"), + trade.log_index + ))?; let transaction = RaindexTransaction::from_local_parts( trade.transaction_hash, trade.transaction_sender, @@ -386,6 +446,9 @@ impl RaindexTrade { Ok(RaindexTrade { id: Bytes::from_str(&trade.trade_id)?, + trade_event_id, + trade_event_kind: trade.trade_kind, + block_number: U256::from(trade.block_number), chain_id, raindex: trade.raindex, order_hash: trade.order_hash, @@ -396,6 +459,7 @@ impl RaindexTrade { timestamp: U256::from(trade.block_timestamp), io_ratio, formatted_io_ratio, + source_log_index: Some(trade.log_index), }) } } @@ -900,6 +964,8 @@ mod test_helpers { serde_json::json!({ "id": "0x0123", "tradeEvent": { + "id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "__typename": "TakeOrder", "transaction": { "id": "0x0000000000000000000000000000000000000000000000000000000000000abc", "from": "0x0000000000000000000000000000000000000000", @@ -1234,6 +1300,8 @@ mod test_helpers { json!( { "id": "0x0123", "tradeEvent": { + "id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "__typename": "TakeOrder", "transaction": { "id": "0x0000000000000000000000000000000000000000000000000000000000000123", "from": "0x0000000000000000000000000000000000000000", @@ -1325,6 +1393,8 @@ mod test_helpers { { "id": "0x0234", "tradeEvent": { + "id": "0x0000000000000000000000000000000000000000000000000000000000000002", + "__typename": "TakeOrder", "transaction": { "id": "0x0000000000000000000000000000000000000000000000000000000000000234", "from": "0x0000000000000000000000000000000000000001", diff --git a/crates/common/src/raindex_client/vaults.rs b/crates/common/src/raindex_client/vaults.rs index 43d3dfae79..cf2f2ef18b 100644 --- a/crates/common/src/raindex_client/vaults.rs +++ b/crates/common/src/raindex_client/vaults.rs @@ -471,6 +471,24 @@ pub struct RaindexVaultToken { decimals: u8, } +impl RaindexVaultToken { + pub(crate) fn raw_address(&self) -> Address { + self.address + } + + pub(crate) fn raw_name(&self) -> Option<&str> { + self.name.as_deref() + } + + pub(crate) fn raw_symbol(&self) -> Option<&str> { + self.symbol.as_deref() + } + + pub(crate) fn raw_decimals(&self) -> u8 { + self.decimals + } +} + #[cfg(target_family = "wasm")] #[wasm_bindgen] impl RaindexVaultToken { @@ -918,6 +936,17 @@ pub struct RaindexVaultBalanceChange { transaction: RaindexTransaction, raindex: Address, } + +#[cfg(not(target_family = "wasm"))] +impl RaindexVaultBalanceChange { + pub(crate) fn raw_token_address(&self) -> Address { + self.token.raw_address() + } + + pub(crate) fn raw_amount(&self) -> Float { + self.amount + } +} #[cfg(target_family = "wasm")] #[wasm_bindgen] impl RaindexVaultBalanceChange { diff --git a/crates/common/src/types/order_takes_list_flattened.rs b/crates/common/src/types/order_takes_list_flattened.rs index ea626caf94..310af39e2b 100644 --- a/crates/common/src/types/order_takes_list_flattened.rs +++ b/crates/common/src/types/order_takes_list_flattened.rs @@ -71,6 +71,8 @@ mod tests { id: SgBytes("trade001".to_string()), timestamp: SgBigInt("1678886400".to_string()), trade_event: SgTradeEvent { + id: SgBytes("event001".to_string()), + __typename: "TakeOrder".to_string(), transaction: SgTransaction { id: SgBytes("tx001".to_string()), from: SgBytes("0xfromAddress".to_string()), diff --git a/crates/quote/src/error.rs b/crates/quote/src/error.rs index c4dcfe4a2f..e8ba91f860 100644 --- a/crates/quote/src/error.rs +++ b/crates/quote/src/error.rs @@ -44,6 +44,8 @@ pub enum Error { SerdeWasmBindgenError(#[from] serde_wasm_bindgen::Error), #[error("Invalid quote target: index {0} is out of bounds for this Order")] InvalidQuoteTarget(U256), + #[error("Quote pair selections must align with orders: expected {expected}, got {actual}")] + PairSelectionLengthMismatch { expected: usize, actual: usize }, #[error(transparent)] ReadProviderError(#[from] ReadProviderError), #[error("Multicall failed: {0}")] diff --git a/crates/quote/src/order_quotes.rs b/crates/quote/src/order_quotes.rs index ac9070e7ab..ca9f08cf34 100644 --- a/crates/quote/src/order_quotes.rs +++ b/crates/quote/src/order_quotes.rs @@ -1,5 +1,7 @@ #[cfg(test)] use crate::injector::NoopInjector; +#[cfg(not(target_family = "wasm"))] +use crate::rpc::{with_rpc_timeout, RPC_ATTEMPT_TIMEOUT_MS}; use crate::{ error::Error, injector::SignedContextInjector, @@ -75,6 +77,21 @@ struct QuotedPairMetadata { pair: Pair, } +fn pair_is_selected( + selected_pairs: Option<&[Vec]>, + order_index: usize, + input_index: u32, + output_index: u32, +) -> bool { + selected_pairs + .and_then(|pairs| pairs.get(order_index)) + .is_none_or(|pairs| { + pairs + .iter() + .any(|pair| pair.input_index == input_index && pair.output_index == output_index) + }) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_family = "wasm", derive(Tsify))] #[serde(rename_all = "camelCase")] @@ -127,6 +144,55 @@ pub async fn get_order_quotes( chunk_size: Option, counterparty: Address, injector: &dyn SignedContextInjector, +) -> Result, Error> { + get_order_quotes_inner( + orders, + None, + block_number, + rpcs, + chunk_size, + counterparty, + injector, + ) + .await +} + +/// Quote only the requested IO pairs for each order. +pub async fn get_order_quotes_for_pairs( + orders: Vec, + selected_pairs: &[Vec], + block_number: Option, + rpcs: Vec, + chunk_size: Option, + counterparty: Address, + injector: &dyn SignedContextInjector, +) -> Result, Error> { + if selected_pairs.len() != orders.len() { + return Err(Error::PairSelectionLengthMismatch { + expected: orders.len(), + actual: selected_pairs.len(), + }); + } + get_order_quotes_inner( + orders, + Some(selected_pairs), + block_number, + rpcs, + chunk_size, + counterparty, + injector, + ) + .await +} + +async fn get_order_quotes_inner( + orders: Vec, + selected_pairs: Option<&[Vec]>, + block_number: Option, + rpcs: Vec, + chunk_size: Option, + counterparty: Address, + injector: &dyn SignedContextInjector, ) -> Result, Error> { let started_at = QuoteTiming::now(); info!( @@ -142,16 +208,23 @@ pub async fn get_order_quotes( let req_block_number = match block_number { Some(block) => block, None => { - let urls = rpcs - .iter() - .map(|rpc| rpc.parse()) - .collect::, _>>() - .map_err(Error::UrlParseError)?; - let provider = mk_read_provider(&urls).map_err(Error::ReadProviderError)?; - provider - .get_block_number() - .await - .map_err(|e| Error::TransportError(e.to_string()))? + #[cfg(target_family = "wasm")] + { + let urls = rpcs + .iter() + .map(|rpc| rpc.parse()) + .collect::, _>>() + .map_err(Error::UrlParseError)?; + let provider = mk_read_provider(&urls).map_err(Error::ReadProviderError)?; + provider + .get_block_number() + .await + .map_err(|error| Error::TransportError(error.to_string()))? + } + #[cfg(not(target_family = "wasm"))] + { + resolve_latest_block_number(&rpcs).await? + } } }; info!( @@ -172,7 +245,7 @@ pub async fn get_order_quotes( let mut skipped_self_trade_pair_count = 0usize; let target_build_started_at = QuoteTiming::now(); - for order in &orders { + for (order_index, order) in orders.iter().enumerate() { let order_struct: OrderV4 = order.clone().try_into()?; let raindex = Address::from_str(&order.raindex.id.0)?; let oracle_url = crate::oracle::extract_oracle_url(order); @@ -183,6 +256,14 @@ pub async fn get_order_quotes( skipped_self_trade_pair_count += 1; continue; } + if !pair_is_selected( + selected_pairs, + order_index, + input_index as u32, + output_index as u32, + ) { + continue; + } let pair_name = format!( "{}/{}", @@ -514,9 +595,101 @@ pub async fn get_order_quotes( Ok(responses) } +#[cfg(not(target_family = "wasm"))] +async fn resolve_latest_block_number(rpcs: &[String]) -> Result { + let mut failures = Vec::new(); + for rpc in rpcs { + let url = match rpc.parse() { + Ok(url) => url, + Err(error) => { + failures.push(format!("{rpc}: {error}")); + continue; + } + }; + let provider = match mk_read_provider(&[url]) { + Ok(provider) => provider, + Err(error) => { + failures.push(format!("{rpc}: {error}")); + continue; + } + }; + match with_rpc_timeout(provider.get_block_number(), RPC_ATTEMPT_TIMEOUT_MS).await { + Some(Ok(value)) => return Ok(value), + Some(Err(error)) => failures.push(format!("{rpc}: {error}")), + None => failures.push(format!("{rpc}: timed out after {RPC_ATTEMPT_TIMEOUT_MS}ms")), + } + } + Err(Error::TransportError(format!( + "all quote RPC block reads failed: {}", + failures.join("; ") + ))) +} + #[cfg(test)] mod tests { use super::*; + + #[test] + fn pair_selection_is_scoped_by_order_and_io_indices() { + let selections = vec![ + vec![Pair { + pair_name: String::new(), + input_index: 1, + output_index: 2, + }], + Vec::new(), + ]; + + assert!(pair_is_selected(Some(&selections), 0, 1, 2)); + assert!(!pair_is_selected(Some(&selections), 0, 0, 2)); + assert!(!pair_is_selected(Some(&selections), 1, 1, 2)); + assert!(pair_is_selected(None, 1, 1, 2)); + } + + #[tokio::test] + async fn pair_selection_must_align_with_orders() { + let error = get_order_quotes_for_pairs( + Vec::new(), + &[Vec::new()], + None, + Vec::new(), + None, + Address::ZERO, + &NoopInjector, + ) + .await + .unwrap_err(); + + assert!(matches!( + error, + Error::PairSelectionLengthMismatch { + expected: 0, + actual: 1 + } + )); + } + + #[cfg(not(target_family = "wasm"))] + #[tokio::test] + async fn latest_block_read_cycles_after_malformed_rpc_url() { + let server = MockServer::start_async().await; + let block = server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x2a", + })); + }); + + let result = resolve_latest_block_number(&["not a URL".into(), server.url("/rpc")]) + .await + .unwrap(); + + assert_eq!(result, 42); + block.assert_hits(1); + } + #[cfg(not(target_family = "wasm"))] use alloy::primitives::{address, Bytes}; use alloy::{ @@ -525,6 +698,8 @@ mod tests { providers::Provider, sol_types::{SolCall, SolValue}, }; + #[cfg(not(target_family = "wasm"))] + use httpmock::{Method::POST, MockServer}; use rain_math_float::Float; #[cfg(not(target_family = "wasm"))] use rain_metadata::types::raindex_signed_context_oracle::RaindexSignedContextOracleV1; @@ -1212,6 +1387,10 @@ amount price: 2 3; .await .unwrap_err(); - assert!(matches!(err, Error::UrlParseError(_))); + assert!(matches!( + err, + Error::TransportError(message) + if message.contains("all quote RPC block reads failed") + )); } } diff --git a/crates/quote/src/quote.rs b/crates/quote/src/quote.rs index 33a6bde186..048e4cfa22 100644 --- a/crates/quote/src/quote.rs +++ b/crates/quote/src/quote.rs @@ -829,9 +829,8 @@ mod tests { raindex, }; - // A malformed RPC body causes per-target `CorruptReturnData` (surfaced - // via the bisection path); the top-level call still returns Ok. - let res = quote_target_specifier + // A malformed RPC response remains aligned with the requested quote. + let result = quote_target_specifier .do_quote( server.url("/sg").as_str(), vec![server.url("/bad-rpc").to_string()], @@ -841,7 +840,11 @@ mod tests { ) .await .unwrap(); - assert!(matches!(res, Err(FailedQuote::CorruptReturnData(_)))); + assert!(matches!( + result, + Err(FailedQuote::CorruptReturnData(message)) + if message.contains("all quote RPCs failed") + )); let err = quote_target_specifier .do_quote( @@ -909,12 +912,9 @@ mod tests { QuoteSpec::default(), ]); - // A transport-layer RPC failure (no body for /bad-rpc) now surfaces - // per-target as `CorruptReturnData` (the OZ multicall path never fails - // at the batch level for transport/revert errors — it bubbles them - // into per-target results via bisection). + // Endpoint failures stay aligned with the quote targets that reached RPC. let bad_rpc_url = rpc_server.url("/bad-rpc").to_string(); - let result = batch_quote_targets_specifiers + let results = batch_quote_targets_specifiers .do_quote( rpc_server.url("/sg").as_str(), vec![bad_rpc_url.clone()], @@ -924,10 +924,14 @@ mod tests { ) .await .unwrap(); - assert_eq!(result.len(), 3); - for r in &result { - assert!(r.is_err(), "expected all targets to fail: {r:?}"); - } + assert_eq!(results.len(), 3); + assert!(matches!( + &results[0], + Err(FailedQuote::CorruptReturnData(message)) + if message.contains("all quote RPCs failed") + )); + assert!(matches!(&results[1], Err(FailedQuote::NonExistent))); + assert!(matches!(&results[2], Err(FailedQuote::NonExistent))); let result = batch_quote_targets_specifiers .do_quote( diff --git a/crates/quote/src/rpc.rs b/crates/quote/src/rpc.rs index 1df58ed630..bf76f8a4ab 100644 --- a/crates/quote/src/rpc.rs +++ b/crates/quote/src/rpc.rs @@ -17,9 +17,23 @@ use rain_error_decoding::{AbiDecodedErrorType, ErrorRegistry}; use raindex_bindings::provider::{mk_read_provider, ReadProvider}; use raindex_bindings::IRaindexV6::quote2Call; use raindex_bindings::Raindex::multicallCall; +#[cfg(not(target_family = "wasm"))] +use std::future::Future; use url::Url; const DEFAULT_QUOTE_CHUNK_SIZE: usize = 16; +#[cfg(not(target_family = "wasm"))] +pub(crate) const RPC_ATTEMPT_TIMEOUT_MS: u64 = 3_000; + +#[cfg(not(target_family = "wasm"))] +pub(crate) async fn with_rpc_timeout(future: F, timeout_ms: u64) -> Option +where + F: Future, +{ + tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), future) + .await + .ok() +} fn normalize_chunk_size(chunk_size: Option) -> usize { chunk_size.unwrap_or(DEFAULT_QUOTE_CHUNK_SIZE).max(1) @@ -90,7 +104,21 @@ async fn quote_chunk_once( .map(|n| BlockId::Number(BlockNumberOrTag::Number(n))) .unwrap_or(BlockId::latest()); - match provider.call(tx).block(block).await { + #[cfg(not(target_family = "wasm"))] + let response = with_rpc_timeout( + async { provider.call(tx).block(block).await }, + RPC_ATTEMPT_TIMEOUT_MS, + ) + .await + .ok_or_else(|| { + Error::TransportError(format!( + "quote request timed out after {RPC_ATTEMPT_TIMEOUT_MS}ms" + )) + })?; + #[cfg(target_family = "wasm")] + let response = provider.call(tx).block(block).await; + + match response { Ok(bytes) => { let decoded = multicallCall::abi_decode_returns(&bytes).map_err(|e| { Error::AlloySolTypesError(alloy::sol_types::Error::Other( @@ -142,10 +170,8 @@ async fn quote_chunk_once( // it to a single target. Err(Error::ChunkReverted(Box::new(decoded))) } else { - Err(Error::ChunkReverted(Box::new( - FailedQuote::CorruptReturnData(format!( - "RPC error without revert data: {err_resp}" - )), + Err(Error::TransportError(format!( + "RPC error without revert data: {err_resp}" ))) } } @@ -177,10 +203,29 @@ async fn quote_chunk_with_probe_and_split( Err(err) => err, }; + // Retrying a transport failure with progressively smaller chunks + // multiplies the same unhealthy RPC request and can turn a single rate + // limit into a request storm. Return it so the caller can cycle RPCs. + if matches!(initial_err, Error::TransportError(_)) { + return Err(initial_err); + } + if quote_targets.len() <= 1 { - // Singleton batch already reverted — attribute the revert to that one - // target using the decoded `FailedQuote` payload. - return Ok(vec![chunk_err_to_quote_result(&initial_err)]); + // A singleton on-chain revert belongs to that target. Decode and + // return it without trying another RPC, because the order itself is + // invalid. Provider-level response errors can still be specific to an + // unhealthy RPC, so let the caller cycle to the next provider. + return if matches!(initial_err, Error::ChunkReverted(_)) { + Ok(vec![chunk_err_to_quote_result(&initial_err)]) + } else { + Err(initial_err) + }; + } + + // Only an on-chain revert from the Raindex multicall can be attributed by + // splitting a multi-target chunk. + if !matches!(initial_err, Error::ChunkReverted(_)) { + return Err(initial_err); } // Bisect unconditionally. A previous version short-circuited here with a @@ -206,7 +251,9 @@ async fn quote_chunk_with_probe_and_split( } } Err(err) => { - if chunk.len() == 1 { + if !matches!(err, Error::ChunkReverted(_)) { + return Err(err); + } else if chunk.len() == 1 { resolved[start] = Some(chunk_err_to_quote_result(&err)); } else { let mid = start + (chunk.len() / 2); @@ -267,15 +314,156 @@ pub async fn batch_quote( registry: Option<&dyn ErrorRegistry>, chunk_size: Option, ) -> Result, Error> { - let rpcs = rpcs - .into_iter() - .map(|rpc| rpc.parse::()) - .collect::, _>>()?; - let provider = mk_read_provider(&rpcs)?; if quote_targets.is_empty() { return Ok(vec![]); } + #[cfg(target_family = "wasm")] + { + let rpcs = rpcs + .into_iter() + .map(|rpc| rpc.parse::()) + .collect::, _>>()?; + let provider = mk_read_provider(&rpcs)?; + return batch_quote_with_provider( + quote_targets, + &provider, + block_number, + counterparty, + registry, + chunk_size, + ) + .await; + } + #[cfg(not(target_family = "wasm"))] + { + let mut transport_failures = Vec::new(); + let mut providers = Vec::new(); + for rpc in &rpcs { + let url = match rpc.parse::() { + Ok(url) => url, + Err(error) => { + transport_failures.push(format!("{rpc}: {error}")); + continue; + } + }; + let provider = match mk_read_provider(std::slice::from_ref(&url)) { + Ok(provider) => provider, + Err(error) => { + transport_failures.push(format!("{rpc}: {error}")); + continue; + } + }; + providers.push((url, provider)); + } + if providers.is_empty() { + let reason = if rpcs.is_empty() { + "no RPC URLs configured".to_string() + } else { + transport_failures.join("; ") + }; + let message = format!("all quote RPCs failed: {reason}"); + return Ok((0..quote_targets.len()) + .map(|_| Err(FailedQuote::CorruptReturnData(message.clone()))) + .collect()); + } + batch_quote_with_providers( + quote_targets, + &providers, + transport_failures, + block_number, + counterparty, + registry, + chunk_size, + ) + .await + } +} + +#[cfg(not(target_family = "wasm"))] +async fn batch_quote_with_providers( + quote_targets: &[QuoteTarget], + providers: &[(Url, ReadProvider)], + provider_setup_failures: Vec, + block_number: Option, + counterparty: Address, + registry: Option<&dyn ErrorRegistry>, + chunk_size: Option, +) -> Result, Error> { + let chunk_size = normalize_chunk_size(chunk_size); + let mut groups: Vec<(Address, Vec<(usize, QuoteTarget)>)> = Vec::new(); + for (index, target) in quote_targets.iter().enumerate() { + if let Some(group) = groups + .iter_mut() + .find(|(raindex, _)| *raindex == target.raindex) + { + group.1.push((index, target.clone())); + } else { + groups.push((target.raindex, vec![(index, target.clone())])); + } + } + + let group_futures = groups.into_iter().map(|(_, indexed_targets)| { + let providers = providers.to_vec(); + let provider_setup_failures = provider_setup_failures.clone(); + async move { + let mut out = Vec::with_capacity(indexed_targets.len()); + let (indexes, targets): (Vec, Vec) = + indexed_targets.into_iter().unzip(); + for chunk_start in (0..targets.len()).step_by(chunk_size) { + let chunk_end = (chunk_start + chunk_size).min(targets.len()); + let chunk = &targets[chunk_start..chunk_end]; + let mut failures = provider_setup_failures.clone(); + let mut chunk_results = None; + for (rpc, provider) in &providers { + match quote_chunk_with_probe_and_split( + chunk, + provider, + block_number, + counterparty, + registry, + ) + .await + { + Ok(results) => { + chunk_results = Some(results); + break; + } + Err(error) => failures.push(format!("{rpc}: {error}")), + } + } + let chunk_results = chunk_results.unwrap_or_else(|| { + let message = format!("all quote RPCs failed: {}", failures.join("; ")); + (0..chunk.len()) + .map(|_| Err(FailedQuote::CorruptReturnData(message.clone()))) + .collect() + }); + for (offset, result) in chunk_results.into_iter().enumerate() { + out.push((indexes[chunk_start + offset], result)); + } + } + Ok::, Error>(out) + } + }); + + let mut results: Vec> = Vec::with_capacity(quote_targets.len()); + results.resize_with(quote_targets.len(), || None); + for group_result in join_all(group_futures).await { + for (index, result) in group_result? { + results[index] = Some(result); + } + } + Ok(results.into_iter().map(Option::unwrap).collect()) +} +#[cfg(target_family = "wasm")] +async fn batch_quote_with_provider( + quote_targets: &[QuoteTarget], + provider: &ReadProvider, + block_number: Option, + counterparty: Address, + registry: Option<&dyn ErrorRegistry>, + chunk_size: Option, +) -> Result, Error> { let chunk_size = normalize_chunk_size(chunk_size); // Group by raindex, preserving the original index of each target so we @@ -411,6 +599,242 @@ mod tests { assert!(result.is_empty()); } + #[tokio::test] + async fn test_batch_quote_without_rpcs_returns_explicit_aligned_failures() { + let result = batch_quote( + &[QuoteTarget::default()], + Vec::new(), + None, + Address::ZERO, + None, + None, + ) + .await + .unwrap(); + + assert!(matches!( + &result[0], + Err(FailedQuote::CorruptReturnData(message)) + if message.contains("no RPC URLs configured") + )); + } + + #[tokio::test] + async fn test_batch_quote_transport_failure_returns_aligned_failures_without_bisection() { + let rpc_server = MockServer::start_async().await; + let failure = rpc_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.status(429).body("rate limited"); + }); + + let result = batch_quote( + &[QuoteTarget::default(), QuoteTarget::default()], + vec![rpc_server.url("/rpc").to_string()], + Some(1), + Address::ZERO, + None, + None, + ) + .await + .unwrap(); + + assert_eq!(result.len(), 2); + assert!(result.iter().all(|result| matches!( + result, + Err(FailedQuote::CorruptReturnData(message)) + if message.contains("all quote RPCs failed") + ))); + failure.assert_hits(1); + } + + #[tokio::test] + async fn test_batch_quote_applies_timeout_per_chunk_request() { + let rpc_server = MockServer::start_async().await; + let one = Float::parse("1".to_string()).unwrap(); + let response = encode_multicall_return(vec![encode_quote2_return_bytes(true, one, one)]); + let quote = rpc_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.delay(std::time::Duration::from_millis(1_600)) + .json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": response, + })); + }); + + let results = batch_quote( + &[QuoteTarget::default(), QuoteTarget::default()], + vec![rpc_server.url("/rpc")], + Some(1), + Address::ZERO, + None, + Some(1), + ) + .await + .unwrap(); + + assert!(results.iter().all(Result::is_ok)); + quote.assert_hits(2); + } + + #[tokio::test] + async fn test_batch_quote_cycles_to_next_rpc_after_transport_failure() { + let failed_server = MockServer::start_async().await; + let failure = failed_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.status(429).body("rate limited"); + }); + let healthy_server = MockServer::start_async().await; + let healthy = healthy_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": encode_multicall_return(vec![encode_quote2_return_bytes( + true, + Float::parse("2".into()).unwrap(), + Float::parse("3".into()).unwrap(), + )]), + })); + }); + + let result = batch_quote( + &[QuoteTarget::default()], + vec![failed_server.url("/rpc"), healthy_server.url("/rpc")], + Some(1), + Address::ZERO, + None, + None, + ) + .await + .unwrap(); + + assert_eq!(result.len(), 1); + assert!( + result[0].is_ok(), + "unexpected quote result: {:?}", + result[0] + ); + failure.assert_hits(1); + healthy.assert_hits(1); + } + + #[tokio::test] + async fn test_batch_quote_cycles_to_next_rpc_after_timeout() { + let stalled_server = MockServer::start_async().await; + let stalled = stalled_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.delay(std::time::Duration::from_millis( + RPC_ATTEMPT_TIMEOUT_MS + 200, + )) + .json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x", + })); + }); + let healthy_server = MockServer::start_async().await; + let healthy = healthy_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": encode_multicall_return(vec![encode_quote2_return_bytes( + true, + Float::parse("2".into()).unwrap(), + Float::parse("3".into()).unwrap(), + )]), + })); + }); + + let result = batch_quote( + &[QuoteTarget::default()], + vec![stalled_server.url("/rpc"), healthy_server.url("/rpc")], + Some(1), + Address::ZERO, + None, + None, + ) + .await + .unwrap(); + + assert!(result[0].is_ok()); + stalled.assert_hits(1); + healthy.assert_hits(1); + } + + #[tokio::test] + async fn test_batch_quote_cycles_to_next_rpc_after_malformed_response() { + let malformed_server = MockServer::start_async().await; + let malformed = malformed_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x", + })); + }); + let healthy_server = MockServer::start_async().await; + let healthy = healthy_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": encode_multicall_return(vec![encode_quote2_return_bytes( + true, + Float::parse("2".into()).unwrap(), + Float::parse("3".into()).unwrap(), + )]), + })); + }); + + let result = batch_quote( + &[QuoteTarget::default()], + vec![malformed_server.url("/rpc"), healthy_server.url("/rpc")], + Some(1), + Address::ZERO, + None, + None, + ) + .await + .unwrap(); + + assert!(result[0].is_ok()); + malformed.assert_hits(1); + healthy.assert_hits(1); + } + + #[tokio::test] + async fn test_batch_quote_cycles_to_next_rpc_after_malformed_url() { + let healthy_server = MockServer::start_async().await; + let healthy = healthy_server.mock(|when, then| { + when.method(POST).path("/rpc"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": encode_multicall_return(vec![encode_quote2_return_bytes( + true, + Float::parse("2".into()).unwrap(), + Float::parse("3".into()).unwrap(), + )]), + })); + }); + + let result = batch_quote( + &[QuoteTarget::default()], + vec!["not a URL".into(), healthy_server.url("/rpc")], + Some(1), + Address::ZERO, + None, + None, + ) + .await + .unwrap(); + + assert!(result[0].is_ok()); + healthy.assert_hits(1); + } + #[tokio::test] async fn test_batch_quote_success() { let rpc_server = MockServer::start_async().await; @@ -580,9 +1004,9 @@ mod tests { } #[tokio::test] - async fn test_batch_quote_invalid_rpc_url_errors() { + async fn test_batch_quote_invalid_rpc_url_returns_aligned_failure() { let quote_targets = vec![QuoteTarget::default()]; - let err = batch_quote( + let result = batch_quote( "e_targets, vec!["this should break".to_string()], None, @@ -591,11 +1015,13 @@ mod tests { None, ) .await - .unwrap_err(); + .unwrap(); + assert_eq!(result.len(), 1); assert!(matches!( - err, - Error::UrlParseError(url::ParseError::RelativeUrlWithoutBase) + &result[0], + Err(FailedQuote::CorruptReturnData(message)) + if message.contains("all quote RPCs failed") )); } @@ -850,6 +1276,67 @@ mod tests { assert!(q2.ratio.eq(six).unwrap()); } + #[tokio::test] + async fn test_batch_quote_retries_only_the_failed_chunk_on_the_next_rpc() { + let first_rpc = MockServer::start_async().await; + let second_rpc = MockServer::start_async().await; + let raindex = "0xcccccccccccccccccccccccccccccccccccccccc" + .parse::
() + .unwrap(); + let first_index = 0xAAAAu64; + let second_index = 0xBBBBu64; + let first_needle = format!("{first_index:064x}"); + let second_needle = format!("{second_index:064x}"); + let one = Float::parse("1".to_string()).unwrap(); + let two = Float::parse("2".to_string()).unwrap(); + let first_return = + encode_multicall_return(vec![encode_quote2_return_bytes(true, one, one)]); + let second_return = + encode_multicall_return(vec![encode_quote2_return_bytes(true, two, two)]); + + let first_success = first_rpc.mock(|when, then| { + when.method(POST) + .path("/rpc") + .body_contains(first_needle.clone()); + then.json_body(json!({"jsonrpc": "2.0", "id": 1, "result": first_return})); + }); + let first_failure = first_rpc.mock(|when, then| { + when.method(POST) + .path("/rpc") + .body_contains(second_needle.clone()); + then.status(429).body("rate limited"); + }); + let second_success = second_rpc.mock(|when, then| { + when.method(POST) + .path("/rpc") + .body_contains(second_needle.clone()); + then.json_body(json!({"jsonrpc": "2.0", "id": 1, "result": second_return})); + }); + + let results = batch_quote( + &[ + target_with(raindex, first_index), + target_with(raindex, second_index), + ], + vec![ + first_rpc.url("/rpc").to_string(), + second_rpc.url("/rpc").to_string(), + ], + None, + Address::ZERO, + None, + Some(1), + ) + .await + .unwrap(); + + assert!(results[0].as_ref().unwrap().ratio.eq(one).unwrap()); + assert!(results[1].as_ref().unwrap().ratio.eq(two).unwrap()); + first_success.assert_hits(1); + first_failure.assert_hits(1); + second_success.assert_hits(1); + } + // A multicall whose returned `bytes[]` length does not match the number of // targets in the chunk is rejected by the length-mismatch guard. With a // single target, the guard fires inside `quote_chunk_once`; the singleton diff --git a/crates/subgraph/src/multi_raindex_client.rs b/crates/subgraph/src/multi_raindex_client.rs index 26937ad3f5..e5fdda12d0 100644 --- a/crates/subgraph/src/multi_raindex_client.rs +++ b/crates/subgraph/src/multi_raindex_client.rs @@ -1132,6 +1132,8 @@ mod tests { fn default_sg_trade_event() -> SgTradeEvent { SgTradeEvent { + id: SgBytes("0xevent_id_default".to_string()), + __typename: "TakeOrder".to_string(), transaction: default_sg_transaction(), sender: SgBytes("0xsender_address_default".to_string()), } diff --git a/crates/subgraph/src/performance/apy.rs b/crates/subgraph/src/performance/apy.rs index d9e86449c6..69d9ca35a0 100644 --- a/crates/subgraph/src/performance/apy.rs +++ b/crates/subgraph/src/performance/apy.rs @@ -350,6 +350,8 @@ mod tests { owner: bytes.clone(), }, trade_event: SgTradeEvent { + id: bytes.clone(), + __typename: "TakeOrder".to_string(), sender: bytes.clone(), transaction: SgTransaction { id: bytes.clone(), @@ -410,6 +412,8 @@ mod tests { owner: bytes.clone(), }, trade_event: SgTradeEvent { + id: bytes.clone(), + __typename: "TakeOrder".to_string(), sender: bytes.clone(), transaction: SgTransaction { id: bytes.clone(), diff --git a/crates/subgraph/src/performance/order_performance.rs b/crates/subgraph/src/performance/order_performance.rs index 957d27875e..5fd0a5f6d2 100644 --- a/crates/subgraph/src/performance/order_performance.rs +++ b/crates/subgraph/src/performance/order_performance.rs @@ -852,6 +852,8 @@ mod test { owner: bytes.clone(), }, trade_event: SgTradeEvent { + id: bytes.clone(), + __typename: "TakeOrder".to_string(), sender: bytes.clone(), transaction: SgTransaction { id: bytes.clone(), @@ -912,6 +914,8 @@ mod test { owner: bytes.clone(), }, trade_event: SgTradeEvent { + id: bytes.clone(), + __typename: "TakeOrder".to_string(), sender: bytes.clone(), transaction: SgTransaction { id: bytes.clone(), diff --git a/crates/subgraph/src/performance/vol.rs b/crates/subgraph/src/performance/vol.rs index 8bf4aead86..721ef05a5c 100644 --- a/crates/subgraph/src/performance/vol.rs +++ b/crates/subgraph/src/performance/vol.rs @@ -235,6 +235,8 @@ mod tests { owner: bytes.clone(), }, trade_event: SgTradeEvent { + id: bytes.clone(), + __typename: "TakeOrder".to_string(), sender: bytes.clone(), transaction: SgTransaction { id: bytes.clone(), @@ -305,6 +307,8 @@ mod tests { owner: bytes.clone(), }, trade_event: SgTradeEvent { + id: bytes.clone(), + __typename: "TakeOrder".to_string(), sender: bytes.clone(), transaction: SgTransaction { id: bytes.clone(), diff --git a/crates/subgraph/src/raindex_client/order_trade.rs b/crates/subgraph/src/raindex_client/order_trade.rs index a8980d20b2..f6712393ae 100644 --- a/crates/subgraph/src/raindex_client/order_trade.rs +++ b/crates/subgraph/src/raindex_client/order_trade.rs @@ -335,6 +335,8 @@ mod tests { fn default_sg_trade_event() -> SgTradeEvent { SgTradeEvent { + id: SgBytes("0xevent_id_default".to_string()), + __typename: "TakeOrder".to_string(), transaction: default_sg_transaction(), sender: SgBytes("0xsender_address_default".to_string()), } diff --git a/crates/subgraph/src/raindex_client/performance.rs b/crates/subgraph/src/raindex_client/performance.rs index 2fb3baf68f..24a732db4e 100644 --- a/crates/subgraph/src/raindex_client/performance.rs +++ b/crates/subgraph/src/raindex_client/performance.rs @@ -80,6 +80,8 @@ mod tests { id: SgBytes("0xraindex_default".to_string()), }, trade_event: SgTradeEvent { + id: SgBytes("0xevent_default".to_string()), + __typename: "TakeOrder".to_string(), transaction: SgTransaction { id: SgBytes("0xtx_default".to_string()), from: SgBytes("0xfrom_default".to_string()), diff --git a/crates/subgraph/src/types/common.rs b/crates/subgraph/src/types/common.rs index e4b06e0fe4..2bf633626e 100644 --- a/crates/subgraph/src/types/common.rs +++ b/crates/subgraph/src/types/common.rs @@ -511,6 +511,9 @@ pub struct SgClearBounty { #[derive(cynic::QueryFragment, Debug, Clone, Serialize, Tsify)] #[cynic(graphql_type = "TradeEvent")] pub struct SgTradeEvent { + pub id: SgBytes, + #[serde(rename = "__typename")] + pub __typename: String, pub transaction: SgTransaction, pub sender: SgBytes, } diff --git a/crates/subgraph/src/types/impls.rs b/crates/subgraph/src/types/impls.rs index 54b5e18e72..201e00e8a0 100644 --- a/crates/subgraph/src/types/impls.rs +++ b/crates/subgraph/src/types/impls.rs @@ -226,6 +226,8 @@ mod tests { SgTrade { id: SgBytes("".to_string()), trade_event: SgTradeEvent { + id: SgBytes("".to_string()), + __typename: "TakeOrder".to_string(), transaction: SgTransaction { id: SgBytes("".to_string()), from: SgBytes("".to_string()), 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 20306524da..021ef73652 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 @@ -7,6 +7,8 @@ query SgOrderTradeDetailQuery($id: ID!) { trade(id: $id) { id tradeEvent { + id + __typename transaction { id from 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 d6e2fcbe91..d90b1bd0a1 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 @@ -7,6 +7,8 @@ query SgOrderTradesListQuery($first: Int, $id: Bytes!, $skip: Int, $timestampGte trades(skip: $skip, first: $first, orderBy: timestamp, orderDirection: desc, where: {order_: {id: $id}, timestamp_gte: $timestampGte, timestamp_lte: $timestampLte}) { id tradeEvent { + id + __typename transaction { id from diff --git a/packages/raindex/test/js_api/raindexClient.test.ts b/packages/raindex/test/js_api/raindexClient.test.ts index 952e690bd4..084519402d 100644 --- a/packages/raindex/test/js_api/raindexClient.test.ts +++ b/packages/raindex/test/js_api/raindexClient.test.ts @@ -398,6 +398,8 @@ describe("Rain Raindex JS API Package Bindgen Tests - Raindex Client", async fun id: "0x07db8b3f3e7498f9d4d0e40b98f57c020d3d277516e86023a8200a20464d4895", timestamp: "1632000000", tradeEvent: { + id: BYTES32_ZERO, + __typename: "TakeOrder", sender: "0x0000000000000000000000000000000000000000", transaction: { id: BYTES32_ZERO, @@ -476,6 +478,8 @@ describe("Rain Raindex JS API Package Bindgen Tests - Raindex Client", async fun id: "0x07db8b3f3e7498f9d4d0e40b98f57c020d3d277516e86023a8200a20464d4894", timestamp: "1632000000", tradeEvent: { + id: BYTES32_ZERO, + __typename: "TakeOrder", sender: "0x0000000000000000000000000000000000000000", transaction: { id: BYTES32_ZERO, @@ -564,6 +568,8 @@ describe("Rain Raindex JS API Package Bindgen Tests - Raindex Client", async fun owner: "0x0000000000000000000000000000000000000000", }, tradeEvent: { + id: BYTES32_0123, + __typename: "TakeOrder", sender: "0x0000000000000000000000000000000000000000", transaction: { id: BYTES32_0123,