Skip to content

Commit 6e8823b

Browse files
Merge branch 'main' into 2026-06-13-issue-2109
2 parents f0e47b3 + 5c0c914 commit 6e8823b

62 files changed

Lines changed: 2425 additions & 83 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/common/src/raindex_client/order_quotes.rs

Lines changed: 252 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use super::*;
22
use crate::raindex_client::orders::RaindexOrder;
33
use crate::raindex_client::orders_list::RaindexOrders;
4+
use crate::raindex_client::vaults::RaindexVault;
45
use crate::utils::timing::Timing;
5-
use alloy::primitives::Address;
6+
use alloy::primitives::{Address, B256};
67
use rain_math_float::Float;
7-
use raindex_bindings::IRaindexV6::{OrderV4, SignedContextV1};
8+
use raindex_bindings::IRaindexV6::{OrderV4, SignedContextV1, IOV2};
89
use 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
}
6979
impl_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+
71111
impl 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

143184
impl 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() {

crates/common/src/raindex_client/orders.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,14 @@ impl RaindexOrder {
461461
RaindexVaultsList::new(get_io_by_type(self, RaindexVaultType::InputOutput))
462462
}
463463
}
464+
impl RaindexOrder {
465+
/// The order's output vaults, in the same order as the subgraph returned
466+
/// them. Unlike [`Self::outputs_list`] this is not filtered by vault type,
467+
/// so vaults that act as both input and output are still included.
468+
pub(crate) fn output_vaults(&self) -> &[RaindexVault] {
469+
&self.outputs
470+
}
471+
}
464472
#[cfg(not(target_family = "wasm"))]
465473
impl RaindexOrder {
466474
pub fn chain_id(&self) -> u32 {
@@ -3964,6 +3972,7 @@ mod tests {
39643972
formatted_ratio: "2".to_string(),
39653973
inverse_ratio,
39663974
formatted_inverse_ratio: "0.5".to_string(),
3975+
formatted_max_output_as_percent_of_vault: None,
39673976
}
39683977
}
39693978

crates/common/src/raindex_client/take_orders/single.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ mod tests {
268268
formatted_ratio: ratio.format().unwrap(),
269269
inverse_ratio: ratio,
270270
formatted_inverse_ratio: ratio.format().unwrap(),
271+
formatted_max_output_as_percent_of_vault: None,
271272
}
272273
}
273274

crates/common/src/raindex_client/vaults.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ impl RaindexVault {
122122
pub(crate) fn vault_id_string(&self) -> String {
123123
self.vault_id.to_string()
124124
}
125+
/// The raw `vaultId` as a `U256`, available on every target (the public
126+
/// `vault_id` getter returns a `BigInt` on wasm and a `U256` off-wasm).
127+
pub(crate) fn raw_vault_id(&self) -> U256 {
128+
self.vault_id
129+
}
130+
/// The vault token's address, available on every target (the public
131+
/// `token().address()` getter returns a `String` on wasm).
132+
pub(crate) fn token_address(&self) -> Address {
133+
self.token.address
134+
}
125135
}
126136

127137
#[cfg(target_family = "wasm")]

crates/common/src/take_orders/candidates.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@ mod tests {
484484
formatted_ratio: "0".to_string(),
485485
inverse_ratio: zero,
486486
formatted_inverse_ratio: "0".to_string(),
487+
formatted_max_output_as_percent_of_vault: None,
487488
}
488489
}
489490

crates/common/src/test_helpers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ pub mod quotes {
630630
formatted_ratio: ratio.format().unwrap(),
631631
inverse_ratio: ratio,
632632
formatted_inverse_ratio: ratio.format().unwrap(),
633+
formatted_max_output_as_percent_of_vault: None,
633634
}
634635
}
635636

0 commit comments

Comments
 (0)