diff --git a/crates/common/src/raindex_client/mod.rs b/crates/common/src/raindex_client/mod.rs index a95e1a0dbd..778cef5fdc 100644 --- a/crates/common/src/raindex_client/mod.rs +++ b/crates/common/src/raindex_client/mod.rs @@ -37,6 +37,7 @@ pub mod local_db; pub mod order_quotes; pub mod orderbook_yaml; pub mod orders; +pub mod orders_list; pub mod remove_orders; pub mod take_orders; pub mod trades; diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index dfe7dd22ba..d3af2c5196 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -1,6 +1,8 @@ use super::*; use crate::raindex_client::orders::RaindexOrder; +use crate::raindex_client::orders_list::RaindexOrders; use rain_math_float::Float; +use rain_orderbook_bindings::IOrderBookV6::OrderV4; use rain_orderbook_quote::{get_order_quotes, BatchOrderQuotesResponse, OrderQuoteValue, Pair}; use rain_orderbook_subgraph_client::utils::float::{F0, F1}; use std::ops::{Div, Mul}; @@ -137,6 +139,125 @@ impl RaindexOrder { } } +#[wasm_export] +impl RaindexClient { + /// Executes quotes for multiple orders in a single multicall + /// + /// This function batches all order pairs into one multicall request, which is + /// significantly more efficient than calling `getQuotes` on each order individually. + /// Results are positionally aligned with the input orders: `result[i]` contains + /// the quotes for `orders[i]`. + /// + /// ## Examples + /// + /// ```javascript + /// const orders = (await client.getOrders()).value; + /// const result = await client.getOrderQuotesBatch(orders, null, null); + /// if (result.error) { + /// console.error("Error:", result.error.readableMsg); + /// return; + /// } + /// for (const [order, quotes] of orders.map((o, i) => [o, result.value[i]])) { + /// console.log("Order", order.orderHash, "quotes:", quotes); + /// } + /// ``` + #[wasm_export( + js_name = "getOrderQuotesBatch", + return_description = "List of quote lists, one per input order, positionally aligned", + unchecked_return_type = "RaindexOrderQuote[][]" + )] + pub async fn get_order_quotes_batch( + &self, + #[wasm_export( + js_name = "orders", + param_description = "List of orders to quote; all must share the same chain" + )] + orders: &RaindexOrders, + #[wasm_export( + js_name = "blockNumber", + param_description = "Optional specific block number for historical quotes (uses latest if None)" + )] + block_number: Option, + #[wasm_export( + js_name = "chunkSize", + param_description = "Optional quote chunk size override (defaults to 16)" + )] + chunk_size: Option, + ) -> Result>, RaindexError> { + get_order_quotes_batch(orders.inner(), block_number, chunk_size).await + } +} + +pub async fn get_order_quotes_batch( + orders: &[RaindexOrder], + block_number: Option, + chunk_size: Option, +) -> Result>, RaindexError> { + if orders.is_empty() { + return Ok(vec![]); + } + + let expected_chain_id = orders[0].chain_id(); + for order in &orders[1..] { + if order.chain_id() != expected_chain_id { + return Err(RaindexError::PreflightError(format!( + "All orders must share the same chain ID, expected {} but found {}", + expected_chain_id, + order.chain_id() + ))); + } + } + + let rpcs: Vec = orders[0] + .get_rpc_urls()? + .into_iter() + .map(|u| u.to_string()) + .collect(); + + let sg_orders = orders + .iter() + .map(|o| o.clone().into_sg_order()) + .collect::, _>>()?; + + let pair_counts: Vec = sg_orders + .iter() + .map(|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 { + count += 1; + } + } + } + Ok::(count) + }) + .collect::, _>>()?; + + let flat_results = get_order_quotes( + sg_orders, + block_number, + rpcs, + chunk_size.map(|v| v as usize), + ) + .await?; + + let flat_raindex: Vec = flat_results + .into_iter() + .map(RaindexOrderQuote::try_from_batch_order_quotes_response) + .collect::, _>>()?; + + let mut result = Vec::with_capacity(orders.len()); + let mut offset = 0; + for count in pair_counts { + result.push(flat_raindex[offset..offset + count].to_vec()); + offset += count; + } + + Ok(result) +} + #[cfg(test)] mod tests { #[cfg(not(target_family = "wasm"))] @@ -251,7 +372,7 @@ mod tests { })); }); - let aggreate_result = vec![Result { + let aggregate_result = vec![Result { success: true, returnData: quoteReturn { exists: true, @@ -261,7 +382,7 @@ mod tests { .abi_encode() .into(), }]; - let response_hex = encode_prefixed(aggreate_result.abi_encode()); + let response_hex = encode_prefixed(aggregate_result.abi_encode()); server.mock(|when, then| { when.path("/rpc"); then.json_body(json!({ @@ -321,6 +442,13 @@ mod tests { assert_eq!(res.pair.output_index, 0); } + #[tokio::test] + async fn test_get_order_quotes_batch_empty() { + let result = get_order_quotes_batch(&[], None, None).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + #[tokio::test] async fn test_get_order_quote_with_chunk_override() { let server = MockServer::start_async().await; @@ -333,7 +461,6 @@ mod tests { })); }); - // block number 1 server.mock(|when, then| { when.path("/rpc").body_contains("blockNumber"); then.json_body(json!({ @@ -387,5 +514,184 @@ mod tests { let res = order.get_quotes(None, Some(8)).await.unwrap(); assert_eq!(res.len(), 1); } + + #[tokio::test] + async fn test_get_order_quotes_batch_single_order() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.path("/sg"); + then.status(200).json_body_obj(&json!({ + "data": { + "orders": [get_order1_json()] + } + })); + }); + + server.mock(|when, then| { + when.path("/rpc").body_contains("blockNumber"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x1", + })); + }); + + let aggregate_result = vec![Result { + success: true, + returnData: quoteReturn { + exists: true, + outputMax: U256::from(1), + ioRatio: U256::from(2), + } + .abi_encode() + .into(), + }]; + let response_hex = encode_prefixed(aggregate_result.abi_encode()); + server.mock(|when, then| { + when.path("/rpc"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": response_hex, + })); + }); + + let raindex_client = RaindexClient::new( + vec![get_test_yaml( + &server.url("/sg"), + "http://localhost:3000", + &server.url("/rpc"), + "http://localhost:3000", + )], + None, + ) + .unwrap(); + let order = raindex_client + .get_order_by_hash( + &OrderbookIdentifier::new( + 1, + Address::from_str(CHAIN_ID_1_ORDERBOOK_ADDRESS).unwrap(), + ), + b256!("0x0000000000000000000000000000000000000000000000000000000000000123"), + ) + .await + .unwrap(); + + let result = get_order_quotes_batch(&[order], None, None).await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].len(), 1); + assert!(result[0][0].success); + assert_eq!(result[0][0].error, None); + assert!(result[0][0] + .data + .as_ref() + .unwrap() + .max_output + .eq(F1) + .unwrap()); + assert!(result[0][0].data.as_ref().unwrap().ratio.eq(F2).unwrap()); + assert_eq!(result[0][0].pair.pair_name, "WFLR/sFLR"); + } + + #[tokio::test] + async fn test_get_order_quotes_batch_multiple_orders() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.path("/sg"); + then.status(200).json_body_obj(&json!({ + "data": { + "orders": [get_order1_json()] + } + })); + }); + + server.mock(|when, then| { + when.path("/rpc").body_contains("blockNumber"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x1", + })); + }); + + let aggregate_result = vec![ + Result { + success: true, + returnData: quoteReturn { + exists: true, + outputMax: U256::from(1), + ioRatio: U256::from(2), + } + .abi_encode() + .into(), + }, + Result { + success: true, + returnData: quoteReturn { + exists: true, + outputMax: U256::from(2), + ioRatio: U256::from(1), + } + .abi_encode() + .into(), + }, + ]; + let response_hex = encode_prefixed(aggregate_result.abi_encode()); + server.mock(|when, then| { + when.path("/rpc"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": response_hex, + })); + }); + + let raindex_client = RaindexClient::new( + vec![get_test_yaml( + &server.url("/sg"), + "http://localhost:3000", + &server.url("/rpc"), + "http://localhost:3000", + )], + None, + ) + .unwrap(); + let order = raindex_client + .get_order_by_hash( + &OrderbookIdentifier::new( + 1, + Address::from_str(CHAIN_ID_1_ORDERBOOK_ADDRESS).unwrap(), + ), + b256!("0x0000000000000000000000000000000000000000000000000000000000000123"), + ) + .await + .unwrap(); + let orders = vec![order.clone(), order]; + + let result = get_order_quotes_batch(&orders, None, None).await.unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].len(), 1); + assert_eq!(result[1].len(), 1); + assert!(result[0][0] + .data + .as_ref() + .unwrap() + .max_output + .eq(F1) + .unwrap()); + assert!(result[0][0].data.as_ref().unwrap().ratio.eq(F2).unwrap()); + assert!(result[1][0] + .data + .as_ref() + .unwrap() + .max_output + .eq(F2) + .unwrap()); + assert!(result[1][0].data.as_ref().unwrap().ratio.eq(F1).unwrap()); + assert_eq!(result[0][0].pair.pair_name, "WFLR/sFLR"); + assert_eq!(result[1][0].pair.pair_name, "WFLR/sFLR"); + } } } diff --git a/crates/common/src/raindex_client/orders_list.rs b/crates/common/src/raindex_client/orders_list.rs new file mode 100644 index 0000000000..db0eb0de26 --- /dev/null +++ b/crates/common/src/raindex_client/orders_list.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; +use wasm_bindgen_utils::prelude::*; + +use crate::raindex_client::orders::RaindexOrder; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[wasm_bindgen] +pub struct RaindexOrders(Vec); + +impl RaindexOrders { + pub fn inner(&self) -> &[RaindexOrder] { + &self.0 + } +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen] +impl RaindexOrders { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn push(&mut self, order: &RaindexOrder) { + self.0.push(order.clone()); + } + + #[wasm_bindgen(getter)] + pub fn items(&self) -> Vec { + self.0.clone() + } +} + +#[cfg(not(target_family = "wasm"))] +impl RaindexOrders { + pub fn new(orders: Vec) -> Self { + Self(orders) + } + + pub fn push(&mut self, order: &RaindexOrder) { + self.0.push(order.clone()); + } + + pub fn items(&self) -> Vec { + self.0.clone() + } +} + +#[cfg(test)] +#[cfg(not(target_family = "wasm"))] +mod tests { + use super::*; + use crate::local_db::OrderbookIdentifier; + use crate::raindex_client::tests::{get_test_yaml, CHAIN_ID_1_ORDERBOOK_ADDRESS}; + use alloy::primitives::{b256, Address}; + use httpmock::MockServer; + use serde_json::json; + use std::str::FromStr; + + async fn make_test_order() -> RaindexOrder { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.path("/sg"); + then.status(200).json_body_obj(&json!({ + "data": { + "orders": [{ + "id": "0x46891c626a8a188610b902ee4a0ce8a7e81915e1b922584f8168d14525899dfb", + "orderBytes": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000005f6c104ca9812ef91fe2e26a2e7187b92d3b0e800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000022009cd210f509c66e18fab61fd30f76fb17c6c6cd09f0972ce0815b5b7630a1b050000000000000000000000005fb33d710f8b58de4c9fdec703b5c2487a5219d600000000000000000000000084c6e7f5a1e5dd89594cc25bef4722a1b8871ae600000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000075000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015020000000c02020002011000000110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000001d80c49bbbcd1c0911346656b529df9e5c2f783d0000000000000000000000000000000000000000000000000000000000000012f5bb1bfe104d351d99dcce1ccfb041ff244a2d3aaf83bd5c4f3fe20b3fceb372000000000000000000000000000000000000000000000000000000000000000100000000000000000000000012e605bc104e93b45e1ad99f9e555f659051c2bb0000000000000000000000000000000000000000000000000000000000000012f5bb1bfe104d351d99dcce1ccfb041ff244a2d3aaf83bd5c4f3fe20b3fceb372", + "orderHash": "0x283508c8f56f4de2f21ee91749d64ec3948c16bc6b4bfe4f8d11e4e67d76f4e0", + "owner": "0x0000000000000000000000000000000000000000", + "outputs": [{ + "id": "0x0000000000000000000000000000000000000000", + "owner": "0xf08bcbce72f62c95dcb7c07dcb5ed26acfcfbc11", + "vaultId": "0x01", + "balance": "0x0000000000000000000000000000000000000000000000000000000000000000", + "token": { + "id": "0x12e605bc104e93b45e1ad99f9e555f659051c2bb", + "address": "0x12e605bc104e93b45e1ad99f9e555f659051c2bb", + "name": "sFLR", "symbol": "sFLR", "decimals": "18" + }, + "orderbook": { "id": CHAIN_ID_1_ORDERBOOK_ADDRESS }, + "ordersAsOutput": [], "ordersAsInput": [], "balanceChanges": [] + }], + "inputs": [{ + "id": "0x0000000000000000000000000000000000000000", + "owner": "0xf08bcbce72f62c95dcb7c07dcb5ed26acfcfbc11", + "vaultId": "0x01", + "balance": "0x0000000000000000000000000000000000000000000000000000000000000000", + "token": { + "id": "0x1d80c49bbbcd1c0911346656b529df9e5c2f783d", + "address": "0x1d80c49bbbcd1c0911346656b529df9e5c2f783d", + "name": "WFLR", "symbol": "WFLR", "decimals": "18" + }, + "orderbook": { "id": CHAIN_ID_1_ORDERBOOK_ADDRESS }, + "ordersAsOutput": [], "ordersAsInput": [], "balanceChanges": [] + }], + "orderbook": { "id": CHAIN_ID_1_ORDERBOOK_ADDRESS }, + "active": true, "timestampAdded": "0", "meta": null, + "addEvents": [], "trades": [], "removeEvents": [] + }] + } + })); + }); + + let client = crate::raindex_client::RaindexClient::new( + vec![get_test_yaml( + &server.url("/sg"), + "http://localhost:3000", + "http://localhost:3000", + "http://localhost:3000", + )], + None, + ) + .unwrap(); + client + .get_order_by_hash( + &OrderbookIdentifier::new( + 1, + Address::from_str(CHAIN_ID_1_ORDERBOOK_ADDRESS).unwrap(), + ), + b256!("0x0000000000000000000000000000000000000000000000000000000000000123"), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn test_new_and_items() { + let order = make_test_order().await; + let list = RaindexOrders::new(vec![order.clone()]); + let items = list.items(); + assert_eq!(items.len(), 1); + } + + #[tokio::test] + async fn test_new_empty() { + let list = RaindexOrders::new(vec![]); + assert!(list.items().is_empty()); + assert!(list.inner().is_empty()); + } + + #[tokio::test] + async fn test_push() { + let order = make_test_order().await; + let mut list = RaindexOrders::new(vec![]); + assert!(list.inner().is_empty()); + + list.push(&order); + assert_eq!(list.items().len(), 1); + + list.push(&order); + assert_eq!(list.items().len(), 2); + } + + #[tokio::test] + async fn test_inner() { + let order = make_test_order().await; + let list = RaindexOrders::new(vec![order.clone(), order]); + assert_eq!(list.inner().len(), 2); + } +} diff --git a/crates/common/src/raindex_client/take_orders/mod.rs b/crates/common/src/raindex_client/take_orders/mod.rs index c66d201818..615ae2426a 100644 --- a/crates/common/src/raindex_client/take_orders/mod.rs +++ b/crates/common/src/raindex_client/take_orders/mod.rs @@ -93,6 +93,7 @@ impl RaindexClient { req.sell_token, req.buy_token, Some(block_number), + None, ) .await?; diff --git a/crates/common/src/raindex_client/take_orders/selection.rs b/crates/common/src/raindex_client/take_orders/selection.rs index 671fe68e26..28a2ec2a93 100644 --- a/crates/common/src/raindex_client/take_orders/selection.rs +++ b/crates/common/src/raindex_client/take_orders/selection.rs @@ -14,9 +14,16 @@ pub(crate) async fn build_candidates_for_chain( sell_token: Address, buy_token: Address, block_number: Option, + chunk_size: Option, ) -> Result, RaindexError> { - let candidates = - build_take_order_candidates_for_pair(orders, sell_token, buy_token, block_number).await?; + let candidates = build_take_order_candidates_for_pair( + orders, + sell_token, + buy_token, + block_number, + chunk_size, + ) + .await?; if candidates.is_empty() { return Err(RaindexError::NoLiquidity); } diff --git a/crates/common/src/take_orders/candidates.rs b/crates/common/src/take_orders/candidates.rs index 0b988951ac..62796bc6d0 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -1,15 +1,12 @@ -use crate::raindex_client::order_quotes::RaindexOrderQuote; +use crate::raindex_client::order_quotes::{get_order_quotes_batch, RaindexOrderQuote}; use crate::raindex_client::orders::RaindexOrder; use crate::raindex_client::RaindexError; use alloy::primitives::Address; -use futures::StreamExt; use rain_math_float::Float; use rain_orderbook_bindings::IOrderBookV6::OrderV4; #[cfg(target_family = "wasm")] use std::str::FromStr; -const DEFAULT_QUOTE_CONCURRENCY: usize = 5; - fn indices_in_bounds(order: &OrderV4, input_index: u32, output_index: u32) -> bool { (input_index as usize) < order.validInputs.len() && (output_index as usize) < order.validOutputs.len() @@ -75,22 +72,14 @@ pub async fn build_take_order_candidates_for_pair( input_token: Address, output_token: Address, block_number: Option, + chunk_size: Option, ) -> Result, RaindexError> { - let quote_results: Vec> = futures::stream::iter( - orders - .iter() - .map(|order| async move { order.get_quotes(block_number, None).await }), - ) - .buffered(DEFAULT_QUOTE_CONCURRENCY) - .collect() - .await; + let all_quotes = get_order_quotes_batch(orders, block_number, chunk_size).await?; orders .iter() - .zip(quote_results) - .map(|(order, quotes_result)| { - build_candidates_for_order(order, quotes_result?, input_token, output_token) - }) + .zip(all_quotes) + .map(|(order, quotes)| build_candidates_for_order(order, quotes, input_token, output_token)) .collect::, _>>() .map(|vecs| vecs.into_iter().flatten().collect()) } diff --git a/packages/orderbook/test/js_api/raindexClient.test.ts b/packages/orderbook/test/js_api/raindexClient.test.ts index 1cebdae492..4150cf8503 100644 --- a/packages/orderbook/test/js_api/raindexClient.test.ts +++ b/packages/orderbook/test/js_api/raindexClient.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, it } from 'vitest'; import { WasmEncodedResult, RaindexClient, + RaindexOrders, SgOrder, SgTrade, // OrderPerformance, TODO: Issue #1989 @@ -938,6 +939,71 @@ describe('Rain Orderbook JS API Package Bindgen Tests - Raindex Client', async f ]); }); + it('should get order quotes batch', async () => { + await mockServer + .forPost('/sg1') + .thenReply(200, JSON.stringify({ data: { orders: [order1] } })); + await mockServer.forPost('/rpc1').once().thenSendJsonRpcResult('0x01'); + // 2-result multicall: elem[0] maxOutput=1/ioRatio=2, elem[1] maxOutput=2/ioRatio=1 + await mockServer + .forPost('/rpc1') + .thenSendJsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001' + ); + + const raindexClient = extractWasmEncodedData(RaindexClient.new([YAML])); + const order = extractWasmEncodedData( + await raindexClient.getOrderByHash(1, CHAIN_ID_1_ORDERBOOK_ADDRESS, BYTES32_0123) + ); + + const orders = new RaindexOrders(); + orders.push(order); + orders.push(order); + const result = extractWasmEncodedData( + await raindexClient.getOrderQuotesBatch(orders) + ); + + assert.equal(result.length, 2); + assert.equal(result[0].length, 1); + assert.equal(result[1].length, 1); + assert.deepEqual(result[0], [ + { + pair: { pairName: 'WFLR/sFLR', inputIndex: 0, outputIndex: 0 }, + blockNumber: 1, + data: { + maxOutput: '0x0000000000000000000000000000000000000000000000000000000000000001', + formattedMaxOutput: '1', + maxInput: '0x0000000000000000000000000000000000000000000000000000000000000002', + formattedMaxInput: '2', + ratio: '0x0000000000000000000000000000000000000000000000000000000000000002', + formattedRatio: '2', + inverseRatio: '0xffffffbd2f7a53a390f4323b0f54bbbb472fa8c5db448df40000000000000000', + formattedInverseRatio: '0.5' + }, + success: true, + error: undefined + } + ]); + assert.deepEqual(result[1], [ + { + pair: { pairName: 'WFLR/sFLR', inputIndex: 0, outputIndex: 0 }, + blockNumber: 1, + data: { + maxOutput: '0x0000000000000000000000000000000000000000000000000000000000000002', + formattedMaxOutput: '2', + maxInput: '0x0000000000000000000000000000000000000000000000000000000000000002', + formattedMaxInput: '2', + ratio: '0x0000000000000000000000000000000000000000000000000000000000000001', + formattedRatio: '1', + inverseRatio: '0xffffffbd5ef4a74721e864761ea977768e5f518bb6891be80000000000000000', + formattedInverseRatio: '1' + }, + success: true, + error: undefined + } + ]); + }); + describe('Trades', async function () { it('should get trades for an order', async function () { await mockServer