diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index 17d1b53787..eac9943ea7 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -1,10 +1,11 @@ use super::*; use crate::raindex_client::orders::RaindexOrder; use crate::raindex_client::orders_list::RaindexOrders; +use crate::raindex_client::vaults::RaindexVault; use crate::utils::timing::Timing; -use alloy::primitives::Address; +use alloy::primitives::{Address, B256}; use rain_math_float::Float; -use raindex_bindings::IRaindexV6::{OrderV4, SignedContextV1}; +use raindex_bindings::IRaindexV6::{OrderV4, SignedContextV1, IOV2}; use raindex_quote::{ get_order_quotes, BatchOrderQuotesResponse, NoopInjector, OrderQuoteValue, Pair, SignedContextInjector, @@ -65,9 +66,48 @@ pub struct RaindexOrderQuoteValue { #[tsify(type = "Hex")] pub inverse_ratio: Float, pub formatted_inverse_ratio: String, + /// `maxOutput` expressed as a percentage of the current balance of the + /// output vault this pair sells from, formatted like the other amounts + /// (e.g. `"25"` for 25%). `None` (omitted) when the output vault cannot be + /// matched or its balance is zero (the percentage would be undefined). + /// Populated after the quote is fetched, from already-fetched vault + /// balances. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + pub formatted_max_output_as_percent_of_vault: Option, } impl_wasm_traits!(RaindexOrderQuoteValue); +/// Formats `amount / balance * 100` the same way the other quote amounts are +/// formatted (so `1` of a `4`-balance vault renders as `"25"`). Returns `None` +/// when `balance` is zero, because the percentage is undefined and dividing +/// would error. +fn format_amount_as_percent_of_balance( + amount: Float, + balance: Float, +) -> Result, RaindexError> { + if balance.is_zero()? { + return Ok(None); + } + let hundred = Float::parse("100".to_string())?; + let percent = amount.div(balance)?.mul(hundred)?; + Ok(Some(percent.format()?)) +} + +/// Finds the balance of the vault whose `(token, vaultId)` matches `io`. The +/// quote pair indices address the on-chain `validInputs`/`validOutputs` +/// (decoded from the order bytes), whose ordering is independent of the +/// subgraph `inputs`/`outputs` arrays, so the match is by identity rather than +/// by position. +fn vault_balance_for_io(io: &IOV2, vaults: &[RaindexVault]) -> Option { + vaults + .iter() + .find(|vault| { + vault.token_address() == io.token && B256::from(vault.raw_vault_id()) == io.vaultId + }) + .map(|vault| vault.balance()) +} + impl RaindexOrderQuoteValue { pub fn try_from_order_quote_value(value: OrderQuoteValue) -> Result { let inverse_ratio = if F0.eq(value.ratio)? { @@ -93,6 +133,7 @@ impl RaindexOrderQuoteValue { formatted_ratio: value.ratio.format()?, inverse_ratio, formatted_inverse_ratio, + formatted_max_output_as_percent_of_vault: None, }) } } @@ -141,6 +182,37 @@ impl RaindexOrder { } impl RaindexOrder { + /// Fills in the per-pair vault-relative percentage on a quote's `data`, + /// so the UI can show each trade's max output amount as a percentage of + /// the output vault it sells from. This is the drawdown signal: how much + /// of the vault a single trade depletes (the input side measures inflow, + /// not drawdown, so it is deliberately not computed). Uses already-fetched + /// subgraph vault balances; never issues a network call. A successful + /// quote with no matching vault (or a zero balance) simply leaves the + /// percentage `None`. `order_v4` must be this order's decoded bytes, whose + /// `validOutputs` the pair output index addresses. + fn enrich_quote_with_vault_percentages( + &self, + quote: &mut RaindexOrderQuote, + order_v4: &OrderV4, + ) -> Result<(), RaindexError> { + let data = match quote.data.as_mut() { + Some(data) => data, + None => return Ok(()), + }; + + let output_balance = order_v4 + .validOutputs + .get(quote.pair.output_index as usize) + .and_then(|io| vault_balance_for_io(io, self.output_vaults())); + if let Some(balance) = output_balance { + data.formatted_max_output_as_percent_of_vault = + format_amount_as_percent_of_balance(data.max_output, balance)?; + } + + Ok(()) + } + /// Non-wasm variant of [`Self::get_quotes`] that threads a `counterparty` /// address and a caller-supplied [`SignedContextInjector`] through to the /// quote RPC. Used by single-take flows that need to populate signed @@ -169,6 +241,7 @@ impl RaindexOrder { let rpcs = self.get_rpc_urls()?; let rpc_url_count = rpcs.len(); let sg_order = self.clone().into_sg_order()?; + let order_v4: OrderV4 = sg_order.clone().try_into()?; info!(rpc_url_count, "starting order quotes"); @@ -194,7 +267,7 @@ impl RaindexOrder { })?; let conversion_started_at = Timing::now(); - let result_order_quotes = order_quotes + let mut result_order_quotes = order_quotes .into_iter() .map(RaindexOrderQuote::try_from_batch_order_quotes_response) .collect::, _>>() @@ -207,6 +280,10 @@ impl RaindexOrder { err })?; + for quote in result_order_quotes.iter_mut() { + self.enrich_quote_with_vault_percentages(quote, &order_v4)?; + } + let successful_quote_count = result_order_quotes .iter() .filter(|quote| quote.success) @@ -410,6 +487,17 @@ pub async fn get_order_quotes_batch_with_injector( result.push(flat_raindex[offset..offset + count].to_vec()); offset += count; } + + // Express each pair's max input/output as a percentage of the vault it + // draws from, using the already-fetched subgraph balances. Done per order + // so the pair indices resolve against that order's own decoded IOs. + for (order, quotes) in orders.iter().zip(result.iter_mut()) { + let order_v4: OrderV4 = order.clone().into_sg_order()?.try_into()?; + for quote in quotes.iter_mut() { + order.enrich_quote_with_vault_percentages(quote, &order_v4)?; + } + } + for (order, quotes) in orders.iter().zip(&result) { for quote in quotes.iter().filter(|quote| !quote.success) { debug!( @@ -614,6 +702,12 @@ mod tests { assert_eq!(data.formatted_ratio, "2"); assert!(data.inverse_ratio.eq(F0_5).unwrap()); assert_eq!(data.formatted_inverse_ratio, "0.5"); + // The subgraph vaults in `get_order1_json` carry a different + // vaultId than the order bytes' validOutputs, so no vault matches + // and the vault-relative percentage stays `None`. This guards + // against blind index-based matching producing a bogus percentage + // from a mismatched vault. + assert_eq!(data.formatted_max_output_as_percent_of_vault, None); assert!(res.success); assert_eq!(res.error, None); assert_eq!(res.pair.pair_name, "WFLR/sFLR"); @@ -621,6 +715,161 @@ mod tests { assert_eq!(res.pair.output_index, 0); } + // Same order bytes as `get_order1_json`, whose decoded validInputs[0] + // (WFLR) and validOutputs[0] (sFLR) both carry vaultId 0x12 (18). We + // point the subgraph vaults at that *same* vaultId so they match the + // on-chain IOs by (token, vaultId), plus round balances (output sFLR = + // 4, input WFLR = 5) so the percentages come out exact. + fn get_order_matching_vaults_json() -> Value { + let mut order = get_order1_json(); + let matching_vault_id = "18"; + order["outputs"][0]["vaultId"] = json!(matching_vault_id); + order["outputs"][0]["balance"] = json!(Float::parse("4".to_string()).unwrap()); + order["inputs"][0]["vaultId"] = json!(matching_vault_id); + order["inputs"][0]["balance"] = json!(Float::parse("5".to_string()).unwrap()); + order + } + + #[tokio::test] + async fn test_get_order_quote_vault_percentages() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.path("/sg"); + then.status(200).json_body_obj(&json!({ + "data": { + "orders": [get_order_matching_vaults_json()] + } + })); + }); + + server.mock(|when, then| { + when.path("/rpc").body_contains("blockNumber"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x1", + })); + }); + + // outputMax = 1 (sFLR), ioRatio = 2 => maxInput = 2 (WFLR). + let response_hex = encode_multicall_bytes(vec![quoteReturn { + exists: true, + outputMax: U256::from(1), + ioRatio: U256::from(2), + }]); + 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, + None, + ) + .await + .unwrap(); + let order = raindex_client + .get_order_by_hash( + &RaindexIdentifier::new( + 1, + Address::from_str(CHAIN_ID_1_RAINDEX_ADDRESS).unwrap(), + ), + b256!("0x0000000000000000000000000000000000000000000000000000000000000123"), + ) + .await + .unwrap(); + let res = order.get_quotes(None, None).await.unwrap(); + assert_eq!(res.len(), 1); + let data = res[0].data.as_ref().unwrap(); + + // The amounts themselves are unchanged: maxOutput = 1, maxInput = 2. + assert_eq!(data.formatted_max_output, "1"); + assert_eq!(data.formatted_max_input, "2"); + // maxOutput 1 / output vault balance 4 * 100 = 25%. Only the output + // side is computed (drawdown signal); there is no input percentage. + assert_eq!( + data.formatted_max_output_as_percent_of_vault, + Some("25".to_string()) + ); + } + + #[tokio::test] + async fn test_get_order_quote_vault_percentages_zero_balance() { + let server = MockServer::start_async().await; + let mut order_json = get_order_matching_vaults_json(); + // Zero the output vault balance: dividing by it is undefined, so the + // output percentage must be `None` even though the output vault + // matches by (token, vaultId). This isolates the divide-by-zero + // branch from the no-match branch (which the sibling test covers). + order_json["outputs"][0]["balance"] = json!(Float::parse("0".to_string()).unwrap()); + server.mock(|when, then| { + when.path("/sg"); + then.status(200).json_body_obj(&json!({ + "data": { "orders": [order_json] } + })); + }); + server.mock(|when, then| { + when.path("/rpc").body_contains("blockNumber"); + then.json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x1", + })); + }); + let response_hex = encode_multicall_bytes(vec![quoteReturn { + exists: true, + outputMax: U256::from(1), + ioRatio: U256::from(2), + }]); + 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, + None, + ) + .await + .unwrap(); + let order = raindex_client + .get_order_by_hash( + &RaindexIdentifier::new( + 1, + Address::from_str(CHAIN_ID_1_RAINDEX_ADDRESS).unwrap(), + ), + b256!("0x0000000000000000000000000000000000000000000000000000000000000123"), + ) + .await + .unwrap(); + let res = order.get_quotes(None, None).await.unwrap(); + let data = res[0].data.as_ref().unwrap(); + // The output amount is still reported; only the percentage is + // suppressed because the vault balance is zero. + assert_eq!(data.formatted_max_output, "1"); + assert_eq!(data.formatted_max_output_as_percent_of_vault, None); + } + #[traced_test] #[tokio::test] async fn test_get_order_quotes_batch_empty() { diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index c2359a02fb..141c3c01dd 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -461,6 +461,14 @@ impl RaindexOrder { RaindexVaultsList::new(get_io_by_type(self, RaindexVaultType::InputOutput)) } } +impl RaindexOrder { + /// 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"))] impl RaindexOrder { pub fn chain_id(&self) -> u32 { @@ -3964,6 +3972,7 @@ mod tests { formatted_ratio: "2".to_string(), inverse_ratio, formatted_inverse_ratio: "0.5".to_string(), + formatted_max_output_as_percent_of_vault: None, } } diff --git a/crates/common/src/raindex_client/take_orders/single.rs b/crates/common/src/raindex_client/take_orders/single.rs index dbfea04e96..faa15b0c11 100644 --- a/crates/common/src/raindex_client/take_orders/single.rs +++ b/crates/common/src/raindex_client/take_orders/single.rs @@ -268,6 +268,7 @@ mod tests { formatted_ratio: ratio.format().unwrap(), inverse_ratio: ratio, formatted_inverse_ratio: ratio.format().unwrap(), + formatted_max_output_as_percent_of_vault: None, } } diff --git a/crates/common/src/raindex_client/vaults.rs b/crates/common/src/raindex_client/vaults.rs index 34d45868c2..b897965cbf 100644 --- a/crates/common/src/raindex_client/vaults.rs +++ b/crates/common/src/raindex_client/vaults.rs @@ -122,6 +122,16 @@ impl RaindexVault { pub(crate) fn vault_id_string(&self) -> String { self.vault_id.to_string() } + /// The raw `vaultId` as a `U256`, available on every target (the public + /// `vault_id` getter returns a `BigInt` on wasm and a `U256` off-wasm). + pub(crate) fn raw_vault_id(&self) -> U256 { + self.vault_id + } + /// The vault token's address, available on every target (the public + /// `token().address()` getter returns a `String` on wasm). + pub(crate) fn token_address(&self) -> Address { + self.token.address + } } #[cfg(target_family = "wasm")] diff --git a/crates/common/src/take_orders/candidates.rs b/crates/common/src/take_orders/candidates.rs index 01ec8a9eec..164ab71b7b 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -484,6 +484,7 @@ mod tests { formatted_ratio: "0".to_string(), inverse_ratio: zero, formatted_inverse_ratio: "0".to_string(), + formatted_max_output_as_percent_of_vault: None, } } diff --git a/crates/common/src/test_helpers.rs b/crates/common/src/test_helpers.rs index 04cd6b788a..c04cd10485 100644 --- a/crates/common/src/test_helpers.rs +++ b/crates/common/src/test_helpers.rs @@ -630,6 +630,7 @@ pub mod quotes { formatted_ratio: ratio.format().unwrap(), inverse_ratio: ratio, formatted_inverse_ratio: ratio.format().unwrap(), + formatted_max_output_as_percent_of_vault: None, } } diff --git a/packages/raindex/test/js_api/raindexClient.test.ts b/packages/raindex/test/js_api/raindexClient.test.ts index 7cc998db2f..616884c010 100644 --- a/packages/raindex/test/js_api/raindexClient.test.ts +++ b/packages/raindex/test/js_api/raindexClient.test.ts @@ -1010,6 +1010,59 @@ describe("Rain Raindex JS API Package Bindgen Tests - Raindex Client", async fun ]); }); + it("should express the order quote max output as a percentage of its output vault balance when the vault matches", async () => { + // order1's decoded on-chain validOutputs[0] references vaultId 0x12, but + // its subgraph output vault carries a different vaultId, so the "should + // get order quote" test above sees no match and the percentage field is + // absent. Here we point the subgraph output vault at the on-chain vaultId + // so the percentage is populated from the already-fetched balance. + const onchainVaultId = "0x12"; + const matchingOrder = JSON.parse(JSON.stringify(order1)) as SgOrder; + matchingOrder.outputs[0].vaultId = onchainVaultId; + + await mockServer + .forPost("/sg1") + .thenReply(200, JSON.stringify({ data: { orders: [matchingOrder] } })); + await mockServer.forPost("/rpc1").once().thenSendJsonRpcResult("0x01"); + await mockServer + .forPost("/rpc1") + .thenSendJsonRpcResult( + "0x0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000001" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000060" + + "0000000000000000000000000000000000000000000000000000000000000001" + + "0000000000000000000000000000000000000000000000000000000000000001" + + "0000000000000000000000000000000000000000000000000000000000000002", + ); + + const raindexClient = extractWasmEncodedData( + await RaindexClient.new([YAML]), + ); + const order = extractWasmEncodedData( + await raindexClient.getOrderByHash( + 1, + CHAIN_ID_1_RAINDEX_ADDRESS, + BYTES32_0123, + ), + ); + + const result = extractWasmEncodedData(await order.getQuotes()); + assert.equal(result.length, 1); + // maxOutput 1 / output vault balance 10 * 100 = 10%. Asserting the exact + // value (not just a non-empty string) proves the computed percentage + // survives the wasm/serde boundary and reaches JS under the camelCase key + // the UI reads. + assert.equal(result[0].data?.formattedMaxOutput, "1"); + assert.equal(result[0].data?.formattedMaxOutputAsPercentOfVault, "10"); + // The input side is deliberately not computed (drawdown is an output-side + // concept), so the field never exists on the wasm boundary. + assert.equal( + "formattedMaxInputAsPercentOfVault" in (result[0].data ?? {}), + false, + ); + }); + it("should get order quotes batch", async () => { await mockServer .forPost("/sg1") diff --git a/packages/ui-components/src/__tests__/TanstackOrderQuote.test.ts b/packages/ui-components/src/__tests__/TanstackOrderQuote.test.ts index b697029e3c..0ac12da207 100644 --- a/packages/ui-components/src/__tests__/TanstackOrderQuote.test.ts +++ b/packages/ui-components/src/__tests__/TanstackOrderQuote.test.ts @@ -374,4 +374,85 @@ describe("TanstackOrderQuote component", () => { expect(maxInputSpan).toHaveTextContent("10.175432109876543210"); }); }); + + it("shows max output as a percentage of the output vault balance under the amount", async () => { + (mockOrder.getQuotes as Mock).mockResolvedValueOnce({ + value: [ + { + success: true, + blockNumber: "0x123", + pair: { pairName: "ETH/USDT", inputIndex: 0, outputIndex: 1 }, + data: { + formattedMaxOutput: "1.5", + formattedRatio: "2", + formattedInverseRatio: "0.5", + formattedMaxInput: "3", + formattedMaxOutputAsPercentOfVault: "25", + }, + error: undefined, + }, + ], + }); + + const queryClient = new QueryClient(); + + render(TanstackOrderQuote, { + props: { + order: mockOrder, + handleQuoteDebugModal: vi.fn(), + }, + context: new Map([["$$_queryClient", queryClient]]), + }); + + await waitFor(() => { + const outputPercent = screen.getByTestId("max-output-percent-0"); + expect(outputPercent).toHaveTextContent("25% of vault"); + }); + + // It renders inside the same cell as the Maximum Output amount, so the + // drawdown percentage sits directly beneath the amount the user reads. + const outputPercent = screen.getByTestId("max-output-percent-0"); + const outputCell = screen.getByText("1.5").closest("td"); + expect(outputCell).not.toBeNull(); + expect(outputCell).toContainElement(outputPercent); + + // The input side is output-only by design: there is no input percentage. + expect(screen.queryByTestId("max-input-percent-0")).toBeNull(); + }); + + it("omits the output vault-percentage when it is not provided", async () => { + (mockOrder.getQuotes as Mock).mockResolvedValueOnce({ + value: [ + { + success: true, + blockNumber: "0x123", + pair: { pairName: "ETH/USDT", inputIndex: 0, outputIndex: 1 }, + data: { + formattedMaxOutput: "1.5", + formattedRatio: "2", + formattedInverseRatio: "0.5", + formattedMaxInput: "3", + // no percentage field (e.g. output vault balance unknown / zero) + }, + error: undefined, + }, + ], + }); + + const queryClient = new QueryClient(); + + render(TanstackOrderQuote, { + props: { + order: mockOrder, + handleQuoteDebugModal: vi.fn(), + }, + context: new Map([["$$_queryClient", queryClient]]), + }); + + await waitFor(() => { + expect(screen.getByTestId("bodyRow")).toHaveTextContent("ETH/USDT"); + }); + expect(screen.queryByTestId("max-output-percent-0")).toBeNull(); + expect(screen.queryByText(/% of vault/)).toBeNull(); + }); }); diff --git a/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte b/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte index 33ed23f15b..38af838084 100644 --- a/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte +++ b/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte @@ -142,6 +142,13 @@ {item.data.formattedMaxOutput} + {#if item.data.formattedMaxOutputAsPercentOfVault} + {item.data.formattedMaxOutputAsPercentOfVault}% of vault + {/if}