11use super :: * ;
22use crate :: raindex_client:: orders:: RaindexOrder ;
33use crate :: raindex_client:: orders_list:: RaindexOrders ;
4+ use crate :: raindex_client:: vaults:: RaindexVault ;
45use crate :: utils:: timing:: Timing ;
5- use alloy:: primitives:: Address ;
6+ use alloy:: primitives:: { Address , B256 } ;
67use rain_math_float:: Float ;
7- use raindex_bindings:: IRaindexV6 :: { OrderV4 , SignedContextV1 } ;
8+ use raindex_bindings:: IRaindexV6 :: { OrderV4 , SignedContextV1 , IOV2 } ;
89use raindex_quote:: {
910 get_order_quotes, BatchOrderQuotesResponse , NoopInjector , OrderQuoteValue , Pair ,
1011 SignedContextInjector ,
@@ -65,9 +66,48 @@ pub struct RaindexOrderQuoteValue {
6566 #[ tsify( type = "Hex" ) ]
6667 pub inverse_ratio : Float ,
6768 pub formatted_inverse_ratio : String ,
69+ /// `maxOutput` expressed as a percentage of the current balance of the
70+ /// output vault this pair sells from, formatted like the other amounts
71+ /// (e.g. `"25"` for 25%). `None` (omitted) when the output vault cannot be
72+ /// matched or its balance is zero (the percentage would be undefined).
73+ /// Populated after the quote is fetched, from already-fetched vault
74+ /// balances.
75+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
76+ #[ tsify( optional) ]
77+ pub formatted_max_output_as_percent_of_vault : Option < String > ,
6878}
6979impl_wasm_traits ! ( RaindexOrderQuoteValue ) ;
7080
81+ /// Formats `amount / balance * 100` the same way the other quote amounts are
82+ /// formatted (so `1` of a `4`-balance vault renders as `"25"`). Returns `None`
83+ /// when `balance` is zero, because the percentage is undefined and dividing
84+ /// would error.
85+ fn format_amount_as_percent_of_balance (
86+ amount : Float ,
87+ balance : Float ,
88+ ) -> Result < Option < String > , RaindexError > {
89+ if balance. is_zero ( ) ? {
90+ return Ok ( None ) ;
91+ }
92+ let hundred = Float :: parse ( "100" . to_string ( ) ) ?;
93+ let percent = amount. div ( balance) ?. mul ( hundred) ?;
94+ Ok ( Some ( percent. format ( ) ?) )
95+ }
96+
97+ /// Finds the balance of the vault whose `(token, vaultId)` matches `io`. The
98+ /// quote pair indices address the on-chain `validInputs`/`validOutputs`
99+ /// (decoded from the order bytes), whose ordering is independent of the
100+ /// subgraph `inputs`/`outputs` arrays, so the match is by identity rather than
101+ /// by position.
102+ fn vault_balance_for_io ( io : & IOV2 , vaults : & [ RaindexVault ] ) -> Option < Float > {
103+ vaults
104+ . iter ( )
105+ . find ( |vault| {
106+ vault. token_address ( ) == io. token && B256 :: from ( vault. raw_vault_id ( ) ) == io. vaultId
107+ } )
108+ . map ( |vault| vault. balance ( ) )
109+ }
110+
71111impl RaindexOrderQuoteValue {
72112 pub fn try_from_order_quote_value ( value : OrderQuoteValue ) -> Result < Self , RaindexError > {
73113 let inverse_ratio = if F0 . eq ( value. ratio ) ? {
@@ -93,6 +133,7 @@ impl RaindexOrderQuoteValue {
93133 formatted_ratio : value. ratio . format ( ) ?,
94134 inverse_ratio,
95135 formatted_inverse_ratio,
136+ formatted_max_output_as_percent_of_vault : None ,
96137 } )
97138 }
98139}
@@ -141,6 +182,37 @@ impl RaindexOrder {
141182}
142183
143184impl RaindexOrder {
185+ /// Fills in the per-pair vault-relative percentage on a quote's `data`,
186+ /// so the UI can show each trade's max output amount as a percentage of
187+ /// the output vault it sells from. This is the drawdown signal: how much
188+ /// of the vault a single trade depletes (the input side measures inflow,
189+ /// not drawdown, so it is deliberately not computed). Uses already-fetched
190+ /// subgraph vault balances; never issues a network call. A successful
191+ /// quote with no matching vault (or a zero balance) simply leaves the
192+ /// percentage `None`. `order_v4` must be this order's decoded bytes, whose
193+ /// `validOutputs` the pair output index addresses.
194+ fn enrich_quote_with_vault_percentages (
195+ & self ,
196+ quote : & mut RaindexOrderQuote ,
197+ order_v4 : & OrderV4 ,
198+ ) -> Result < ( ) , RaindexError > {
199+ let data = match quote. data . as_mut ( ) {
200+ Some ( data) => data,
201+ None => return Ok ( ( ) ) ,
202+ } ;
203+
204+ let output_balance = order_v4
205+ . validOutputs
206+ . get ( quote. pair . output_index as usize )
207+ . and_then ( |io| vault_balance_for_io ( io, self . output_vaults ( ) ) ) ;
208+ if let Some ( balance) = output_balance {
209+ data. formatted_max_output_as_percent_of_vault =
210+ format_amount_as_percent_of_balance ( data. max_output , balance) ?;
211+ }
212+
213+ Ok ( ( ) )
214+ }
215+
144216 /// Non-wasm variant of [`Self::get_quotes`] that threads a `counterparty`
145217 /// address and a caller-supplied [`SignedContextInjector`] through to the
146218 /// quote RPC. Used by single-take flows that need to populate signed
@@ -169,6 +241,7 @@ impl RaindexOrder {
169241 let rpcs = self . get_rpc_urls ( ) ?;
170242 let rpc_url_count = rpcs. len ( ) ;
171243 let sg_order = self . clone ( ) . into_sg_order ( ) ?;
244+ let order_v4: OrderV4 = sg_order. clone ( ) . try_into ( ) ?;
172245
173246 info ! ( rpc_url_count, "starting order quotes" ) ;
174247
@@ -194,7 +267,7 @@ impl RaindexOrder {
194267 } ) ?;
195268
196269 let conversion_started_at = Timing :: now ( ) ;
197- let result_order_quotes = order_quotes
270+ let mut result_order_quotes = order_quotes
198271 . into_iter ( )
199272 . map ( RaindexOrderQuote :: try_from_batch_order_quotes_response)
200273 . collect :: < Result < Vec < _ > , _ > > ( )
@@ -207,6 +280,10 @@ impl RaindexOrder {
207280 err
208281 } ) ?;
209282
283+ for quote in result_order_quotes. iter_mut ( ) {
284+ self . enrich_quote_with_vault_percentages ( quote, & order_v4) ?;
285+ }
286+
210287 let successful_quote_count = result_order_quotes
211288 . iter ( )
212289 . filter ( |quote| quote. success )
@@ -410,6 +487,17 @@ pub async fn get_order_quotes_batch_with_injector(
410487 result. push ( flat_raindex[ offset..offset + count] . to_vec ( ) ) ;
411488 offset += count;
412489 }
490+
491+ // Express each pair's max input/output as a percentage of the vault it
492+ // draws from, using the already-fetched subgraph balances. Done per order
493+ // so the pair indices resolve against that order's own decoded IOs.
494+ for ( order, quotes) in orders. iter ( ) . zip ( result. iter_mut ( ) ) {
495+ let order_v4: OrderV4 = order. clone ( ) . into_sg_order ( ) ?. try_into ( ) ?;
496+ for quote in quotes. iter_mut ( ) {
497+ order. enrich_quote_with_vault_percentages ( quote, & order_v4) ?;
498+ }
499+ }
500+
413501 for ( order, quotes) in orders. iter ( ) . zip ( & result) {
414502 for quote in quotes. iter ( ) . filter ( |quote| !quote. success ) {
415503 debug ! (
@@ -614,13 +702,174 @@ mod tests {
614702 assert_eq ! ( data. formatted_ratio, "2" ) ;
615703 assert ! ( data. inverse_ratio. eq( F0_5 ) . unwrap( ) ) ;
616704 assert_eq ! ( data. formatted_inverse_ratio, "0.5" ) ;
705+ // The subgraph vaults in `get_order1_json` carry a different
706+ // vaultId than the order bytes' validOutputs, so no vault matches
707+ // and the vault-relative percentage stays `None`. This guards
708+ // against blind index-based matching producing a bogus percentage
709+ // from a mismatched vault.
710+ assert_eq ! ( data. formatted_max_output_as_percent_of_vault, None ) ;
617711 assert ! ( res. success) ;
618712 assert_eq ! ( res. error, None ) ;
619713 assert_eq ! ( res. pair. pair_name, "WFLR/sFLR" ) ;
620714 assert_eq ! ( res. pair. input_index, 0 ) ;
621715 assert_eq ! ( res. pair. output_index, 0 ) ;
622716 }
623717
718+ // Same order bytes as `get_order1_json`, whose decoded validInputs[0]
719+ // (WFLR) and validOutputs[0] (sFLR) both carry vaultId 0x12 (18). We
720+ // point the subgraph vaults at that *same* vaultId so they match the
721+ // on-chain IOs by (token, vaultId), plus round balances (output sFLR =
722+ // 4, input WFLR = 5) so the percentages come out exact.
723+ fn get_order_matching_vaults_json ( ) -> Value {
724+ let mut order = get_order1_json ( ) ;
725+ let matching_vault_id = "18" ;
726+ order[ "outputs" ] [ 0 ] [ "vaultId" ] = json ! ( matching_vault_id) ;
727+ order[ "outputs" ] [ 0 ] [ "balance" ] = json ! ( Float :: parse( "4" . to_string( ) ) . unwrap( ) ) ;
728+ order[ "inputs" ] [ 0 ] [ "vaultId" ] = json ! ( matching_vault_id) ;
729+ order[ "inputs" ] [ 0 ] [ "balance" ] = json ! ( Float :: parse( "5" . to_string( ) ) . unwrap( ) ) ;
730+ order
731+ }
732+
733+ #[ tokio:: test]
734+ async fn test_get_order_quote_vault_percentages ( ) {
735+ let server = MockServer :: start_async ( ) . await ;
736+ server. mock ( |when, then| {
737+ when. path ( "/sg" ) ;
738+ then. status ( 200 ) . json_body_obj ( & json ! ( {
739+ "data" : {
740+ "orders" : [ get_order_matching_vaults_json( ) ]
741+ }
742+ } ) ) ;
743+ } ) ;
744+
745+ server. mock ( |when, then| {
746+ when. path ( "/rpc" ) . body_contains ( "blockNumber" ) ;
747+ then. json_body ( json ! ( {
748+ "jsonrpc" : "2.0" ,
749+ "id" : 1 ,
750+ "result" : "0x1" ,
751+ } ) ) ;
752+ } ) ;
753+
754+ // outputMax = 1 (sFLR), ioRatio = 2 => maxInput = 2 (WFLR).
755+ let response_hex = encode_multicall_bytes ( vec ! [ quoteReturn {
756+ exists: true ,
757+ outputMax: U256 :: from( 1 ) ,
758+ ioRatio: U256 :: from( 2 ) ,
759+ } ] ) ;
760+ server. mock ( |when, then| {
761+ when. path ( "/rpc" ) ;
762+ then. json_body ( json ! ( {
763+ "jsonrpc" : "2.0" ,
764+ "id" : 1 ,
765+ "result" : response_hex,
766+ } ) ) ;
767+ } ) ;
768+
769+ let raindex_client = RaindexClient :: new (
770+ vec ! [ get_test_yaml(
771+ & server. url( "/sg" ) ,
772+ "http://localhost:3000" ,
773+ & server. url( "/rpc" ) ,
774+ "http://localhost:3000" ,
775+ ) ] ,
776+ None ,
777+ None ,
778+ )
779+ . await
780+ . unwrap ( ) ;
781+ let order = raindex_client
782+ . get_order_by_hash (
783+ & RaindexIdentifier :: new (
784+ 1 ,
785+ Address :: from_str ( CHAIN_ID_1_RAINDEX_ADDRESS ) . unwrap ( ) ,
786+ ) ,
787+ b256 ! ( "0x0000000000000000000000000000000000000000000000000000000000000123" ) ,
788+ )
789+ . await
790+ . unwrap ( ) ;
791+ let res = order. get_quotes ( None , None ) . await . unwrap ( ) ;
792+ assert_eq ! ( res. len( ) , 1 ) ;
793+ let data = res[ 0 ] . data . as_ref ( ) . unwrap ( ) ;
794+
795+ // The amounts themselves are unchanged: maxOutput = 1, maxInput = 2.
796+ assert_eq ! ( data. formatted_max_output, "1" ) ;
797+ assert_eq ! ( data. formatted_max_input, "2" ) ;
798+ // maxOutput 1 / output vault balance 4 * 100 = 25%. Only the output
799+ // side is computed (drawdown signal); there is no input percentage.
800+ assert_eq ! (
801+ data. formatted_max_output_as_percent_of_vault,
802+ Some ( "25" . to_string( ) )
803+ ) ;
804+ }
805+
806+ #[ tokio:: test]
807+ async fn test_get_order_quote_vault_percentages_zero_balance ( ) {
808+ let server = MockServer :: start_async ( ) . await ;
809+ let mut order_json = get_order_matching_vaults_json ( ) ;
810+ // Zero the output vault balance: dividing by it is undefined, so the
811+ // output percentage must be `None` even though the output vault
812+ // matches by (token, vaultId). This isolates the divide-by-zero
813+ // branch from the no-match branch (which the sibling test covers).
814+ order_json[ "outputs" ] [ 0 ] [ "balance" ] = json ! ( Float :: parse( "0" . to_string( ) ) . unwrap( ) ) ;
815+ server. mock ( |when, then| {
816+ when. path ( "/sg" ) ;
817+ then. status ( 200 ) . json_body_obj ( & json ! ( {
818+ "data" : { "orders" : [ order_json] }
819+ } ) ) ;
820+ } ) ;
821+ server. mock ( |when, then| {
822+ when. path ( "/rpc" ) . body_contains ( "blockNumber" ) ;
823+ then. json_body ( json ! ( {
824+ "jsonrpc" : "2.0" ,
825+ "id" : 1 ,
826+ "result" : "0x1" ,
827+ } ) ) ;
828+ } ) ;
829+ let response_hex = encode_multicall_bytes ( vec ! [ quoteReturn {
830+ exists: true ,
831+ outputMax: U256 :: from( 1 ) ,
832+ ioRatio: U256 :: from( 2 ) ,
833+ } ] ) ;
834+ server. mock ( |when, then| {
835+ when. path ( "/rpc" ) ;
836+ then. json_body ( json ! ( {
837+ "jsonrpc" : "2.0" ,
838+ "id" : 1 ,
839+ "result" : response_hex,
840+ } ) ) ;
841+ } ) ;
842+
843+ let raindex_client = RaindexClient :: new (
844+ vec ! [ get_test_yaml(
845+ & server. url( "/sg" ) ,
846+ "http://localhost:3000" ,
847+ & server. url( "/rpc" ) ,
848+ "http://localhost:3000" ,
849+ ) ] ,
850+ None ,
851+ None ,
852+ )
853+ . await
854+ . unwrap ( ) ;
855+ let order = raindex_client
856+ . get_order_by_hash (
857+ & RaindexIdentifier :: new (
858+ 1 ,
859+ Address :: from_str ( CHAIN_ID_1_RAINDEX_ADDRESS ) . unwrap ( ) ,
860+ ) ,
861+ b256 ! ( "0x0000000000000000000000000000000000000000000000000000000000000123" ) ,
862+ )
863+ . await
864+ . unwrap ( ) ;
865+ let res = order. get_quotes ( None , None ) . await . unwrap ( ) ;
866+ let data = res[ 0 ] . data . as_ref ( ) . unwrap ( ) ;
867+ // The output amount is still reported; only the percentage is
868+ // suppressed because the vault balance is zero.
869+ assert_eq ! ( data. formatted_max_output, "1" ) ;
870+ assert_eq ! ( data. formatted_max_output_as_percent_of_vault, None ) ;
871+ }
872+
624873 #[ traced_test]
625874 #[ tokio:: test]
626875 async fn test_get_order_quotes_batch_empty ( ) {
0 commit comments