1use std::collections::VecDeque;
2
3use num_bigint::BigUint;
4use num_traits::{CheckedSub, ToPrimitive, Zero};
5use rustc_hash::{FxHashMap, FxHashSet};
6use tycho_simulation::tycho_common::{
7 dto::ProtocolStateDelta,
8 models::token::Token,
9 simulation::{
10 errors::{SimulationError, TransitionError},
11 protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
12 },
13 Bytes,
14};
15
16use super::{sim_meter, sim_meter::MeteredProtocolSim};
17use crate::{
18 algorithm::AlgorithmError,
19 feed::market_data::MarketState,
20 types::{ComponentId, Order, Route, Swap},
21};
22
23const SIMULATE_PATH_STAGE: sim_meter::StageLabel = "simulate_path";
25
26const SPLIT_PLAN_STAGE: sim_meter::StageLabel = "execute_split_plan";
28
29#[derive(Clone)]
30pub struct HopDescriptor {
32 pub component_id: ComponentId,
34 pub token_in: Token,
36 pub token_out: Token,
38}
39
40impl HopDescriptor {
41 pub fn new(component_id: ComponentId, token_in: Token, token_out: Token) -> Self {
43 Self { component_id, token_in, token_out }
44 }
45
46 #[cfg(test)]
47 pub fn with_amounts(self, amount_out: BigUint, gas: BigUint) -> SimulatedHop {
49 SimulatedHop { descriptor: self, amount_out, gas }
50 }
51}
52
53#[derive(Clone)]
57pub struct SimulatedHop {
58 pub descriptor: HopDescriptor,
60 pub amount_out: BigUint,
62 pub gas: BigUint,
64}
65
66#[derive(Clone)]
71pub struct PathAllocation {
72 pub hops: Vec<SimulatedHop>,
74 pub flow_fraction: f64,
76 pub amount_in: BigUint,
78 pub amount_out: BigUint,
80 pub marginal_price_product: f64,
83}
84
85impl PathAllocation {
86 pub fn validate_token_cycles(&self) -> Result<(), AlgorithmError> {
92 if self.hops.is_empty() {
93 return Err(AlgorithmError::Other("path has no hops".to_string()));
94 }
95 let first_token = &self.hops[0].descriptor.token_in.address;
96 let mut seen = FxHashSet::default();
97 seen.insert(first_token.clone());
98 let last_idx = self.hops.len() - 1;
99 for (i, hop) in self.hops.iter().enumerate() {
100 let out_addr = &hop.descriptor.token_out.address;
101 if !seen.insert(out_addr.clone()) {
102 let is_valid_round_trip = i == last_idx && out_addr == first_token;
103 if !is_valid_round_trip {
104 return Err(AlgorithmError::Other(format!(
105 "path revisits token {out_addr} at hop {i} \
106 (would corrupt merge_shared_hops)",
107 )));
108 }
109 }
110 }
111 Ok(())
112 }
113}
114
115pub struct SimResult {
117 pub amount_out: BigUint,
119 pub marginal_price_product: f64,
121 pub hop_results: Vec<(BigUint, BigUint)>,
123 pub post_swap_states: Vec<(ComponentId, Box<dyn ProtocolSim>)>,
126}
127
128#[derive(Default)]
130pub struct MarketOverrides(FxHashMap<ComponentId, Box<dyn ProtocolSim>>);
131
132impl MarketOverrides {
133 #[must_use]
135 pub fn empty() -> Self {
136 Self::default()
137 }
138
139 pub fn with_override(mut self, id: ComponentId, sim: Box<dyn ProtocolSim>) -> Self {
141 self.0.insert(id, sim);
142 self
143 }
144
145 pub fn with_zero_gas(mut self, id: ComponentId, token_in: Bytes, token_out: Bytes) -> Self {
156 if let Some(sim) = self.0.remove(&id) {
157 let wrapped = if let Some(selective) = sim
159 .as_any()
160 .downcast_ref::<SelectiveZeroGasSim>()
161 {
162 let mut pairs = selective.zero_gas_pairs.clone();
163 pairs.insert((token_in, token_out));
164 Box::new(SelectiveZeroGasSim {
165 inner: selective.inner.clone_box(),
166 zero_gas_pairs: pairs,
167 }) as Box<dyn ProtocolSim>
168 } else {
169 let mut pairs = FxHashSet::default();
170 pairs.insert((token_in, token_out));
171 Box::new(SelectiveZeroGasSim { inner: sim, zero_gas_pairs: pairs })
172 };
173 self.0.insert(id, wrapped);
174 }
175 self
176 }
177
178 #[must_use]
180 pub fn get(&self, id: &ComponentId) -> Option<&dyn ProtocolSim> {
181 self.0.get(id).map(|b| b.as_ref())
182 }
183
184 pub fn insert(&mut self, id: ComponentId, sim: Box<dyn ProtocolSim>) {
189 self.0.insert(id, sim);
190 }
191}
192
193#[derive(Debug, serde::Serialize, serde::Deserialize)]
197struct SelectiveZeroGasSim {
198 inner: Box<dyn ProtocolSim>,
199 zero_gas_pairs: FxHashSet<(Bytes, Bytes)>,
200}
201
202#[typetag::serde]
203impl ProtocolSim for SelectiveZeroGasSim {
204 fn fee(&self) -> f64 {
205 self.inner.fee()
206 }
207
208 fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
209 self.inner.spot_price(base, quote)
210 }
211
212 fn get_amount_out(
213 &self,
214 amount_in: BigUint,
215 token_in: &Token,
216 token_out: &Token,
217 ) -> Result<GetAmountOutResult, SimulationError> {
218 let mut result = self
219 .inner
220 .get_amount_out(amount_in, token_in, token_out)?;
221 if self
222 .zero_gas_pairs
223 .contains(&(token_in.address.clone(), token_out.address.clone()))
224 {
225 result.gas = BigUint::ZERO;
226 }
227 result.new_state = Box::new(SelectiveZeroGasSim {
228 inner: result.new_state,
229 zero_gas_pairs: self.zero_gas_pairs.clone(),
230 });
231 Ok(result)
232 }
233
234 fn get_limits(
235 &self,
236 sell_token: Bytes,
237 buy_token: Bytes,
238 ) -> Result<(BigUint, BigUint), SimulationError> {
239 self.inner
240 .get_limits(sell_token, buy_token)
241 }
242
243 fn delta_transition(
244 &mut self,
245 delta: ProtocolStateDelta,
246 tokens: &std::collections::HashMap<Bytes, Token>,
247 balances: &Balances,
248 ) -> Result<(), TransitionError> {
249 self.inner
250 .delta_transition(delta, tokens, balances)
251 }
252
253 fn clone_box(&self) -> Box<dyn ProtocolSim> {
254 Box::new(SelectiveZeroGasSim {
255 inner: self.inner.clone_box(),
256 zero_gas_pairs: self.zero_gas_pairs.clone(),
257 })
258 }
259
260 fn as_any(&self) -> &dyn std::any::Any {
261 self
262 }
263
264 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
265 self
266 }
267
268 fn eq(&self, other: &dyn ProtocolSim) -> bool {
269 other
270 .as_any()
271 .downcast_ref::<Self>()
272 .map(|o| self.inner.eq(&*o.inner) && self.zero_gas_pairs == o.zero_gas_pairs)
273 .unwrap_or(false)
274 }
275}
276
277pub fn golden_section_search(
282 mut f: impl FnMut(f64) -> f64,
283 mut lo: f64,
284 mut hi: f64,
285 max_evals: usize,
286) -> f64 {
287 let inv_phi = (5_f64.sqrt() - 1.0) / 2.0;
288
289 let mut x1 = hi - inv_phi * (hi - lo);
290 let mut x2 = lo + inv_phi * (hi - lo);
291 let mut f1 = f(x1);
292 let mut f2 = f(x2);
293 let remaining = max_evals.saturating_sub(2);
295
296 for _ in 0..remaining {
297 if f1 < f2 {
298 lo = x1;
299 x1 = x2;
300 f1 = f2;
301 x2 = lo + inv_phi * (hi - lo);
302 f2 = f(x2);
303 } else {
304 hi = x2;
305 x2 = x1;
306 f2 = f1;
307 x1 = hi - inv_phi * (hi - lo);
308 f1 = f(x1);
309 }
310 }
311
312 if f1 >= f2 {
313 x1
314 } else {
315 x2
316 }
317}
318
319pub fn split_amount(total: &BigUint, fraction: f64) -> (BigUint, BigUint) {
324 let clamped = fraction.clamp(0.0, 1.0);
325 let scale: u64 = 1_000_000_000_000_000_000;
327 let numerator = (clamped * scale as f64) as u64;
328 let part = (total * BigUint::from(numerator)) / BigUint::from(scale);
329 let remainder = total - ∂
330 (part, remainder)
331}
332
333#[derive(Debug, Clone, PartialEq, thiserror::Error)]
335pub enum SplitMathError {
336 #[error("fractions slice must not be empty")]
338 EmptyFractions,
339 #[error("all fractions are zero, cannot normalize")]
341 AllZeroFractions,
342 #[error("fractions must not be negative")]
344 NegativeFraction,
345 #[error("the fractions ask for {asked} of {total}")]
347 ExceedsTotal {
348 asked: BigUint,
350 total: BigUint,
352 },
353}
354
355pub fn normalize_fractions(fractions: &mut [f64]) -> Result<(), SplitMathError> {
362 if fractions.is_empty() {
363 return Err(SplitMathError::EmptyFractions);
364 }
365 if fractions.iter().any(|&f| f < 0.0) {
366 return Err(SplitMathError::NegativeFraction);
367 }
368 let sum: f64 = fractions.iter().sum();
369 if sum == 0.0 {
370 return Err(SplitMathError::AllZeroFractions);
371 }
372 for f in fractions.iter_mut() {
373 *f /= sum;
374 }
375 Ok(())
376}
377
378pub fn fractions_to_amounts(
390 total: &BigUint,
391 fractions: &[f64],
392) -> Result<Vec<BigUint>, SplitMathError> {
393 if fractions.is_empty() {
394 return Err(SplitMathError::EmptyFractions);
395 }
396 let n = fractions.len();
397 let mut amounts = Vec::with_capacity(n);
398 let mut running_sum = BigUint::zero();
399
400 for &frac in &fractions[..n - 1] {
401 let (part, _) = split_amount(total, frac);
402 running_sum += ∂
403 amounts.push(part);
404 }
405
406 let remainder = total
409 .checked_sub(&running_sum)
410 .ok_or_else(|| SplitMathError::ExceedsTotal {
411 asked: running_sum.clone(),
412 total: total.clone(),
413 })?;
414 amounts.push(remainder);
415 Ok(amounts)
416}
417
418pub fn compute_marginal_price_product(
421 hops: &[HopDescriptor],
422 market: &MarketState,
423 overrides: &MarketOverrides,
424) -> Result<f64, AlgorithmError> {
425 let mut product = 1.0;
426 for hop in hops {
427 let sim = overrides
428 .get(&hop.component_id)
429 .or_else(|| market.get_simulation_state(&hop.component_id))
430 .ok_or_else(|| AlgorithmError::DataNotFound {
431 kind: "simulation state",
432 id: Some(hop.component_id.clone()),
433 })?;
434 let price = sim
435 .spot_price(&hop.token_in, &hop.token_out)
436 .map_err(|e| AlgorithmError::SimulationFailed {
437 component_id: hop.component_id.clone(),
438 error: e.to_string(),
439 })?;
440 product *= price;
441 }
442 Ok(product)
443}
444
445pub fn simulate_path(
453 hops: &[HopDescriptor],
454 amount_in: &BigUint,
455 market: &MarketState,
456 overrides: &MarketOverrides,
457) -> Result<SimResult, AlgorithmError> {
458 let mut current_amount = amount_in.clone();
459 let mut hop_results = Vec::with_capacity(hops.len());
460 let mut post_swap_states: Vec<(ComponentId, Box<dyn ProtocolSim>)> =
461 Vec::with_capacity(hops.len());
462 let mut marginal_price_product = 1.0;
463
464 for hop in hops {
465 let sim = post_swap_states
468 .iter()
469 .rev()
470 .find(|(id, _)| id == &hop.component_id)
471 .map(|(_, state)| state.as_ref())
472 .or_else(|| overrides.get(&hop.component_id))
473 .or_else(|| market.get_simulation_state(&hop.component_id))
474 .ok_or_else(|| AlgorithmError::DataNotFound {
475 kind: "simulation state",
476 id: Some(hop.component_id.clone()),
477 })?;
478
479 let price = sim
480 .spot_price(&hop.token_in, &hop.token_out)
481 .map_err(|e| AlgorithmError::SimulationFailed {
482 component_id: hop.component_id.clone(),
483 error: e.to_string(),
484 })?;
485 marginal_price_product *= price;
486
487 let result = sim
488 .get_amount_out_metered(
489 &hop.component_id,
490 SIMULATE_PATH_STAGE,
491 current_amount,
492 &hop.token_in,
493 &hop.token_out,
494 )
495 .map_err(|e| AlgorithmError::SimulationFailed {
496 component_id: hop.component_id.clone(),
497 error: e.to_string(),
498 })?;
499
500 hop_results.push((result.amount.clone(), result.gas));
501 current_amount = result.amount;
502 post_swap_states.push((hop.component_id.clone(), result.new_state));
503 }
504
505 Ok(SimResult {
506 amount_out: current_amount,
507 marginal_price_product,
508 hop_results,
509 post_swap_states,
510 })
511}
512
513pub fn build_post_swap_overrides(
527 paths: &[PathAllocation],
528 market: &MarketState,
529) -> Result<MarketOverrides, AlgorithmError> {
530 let Some(root_hop) = paths
531 .first()
532 .and_then(|path| path.hops.first())
533 else {
534 return Ok(MarketOverrides::empty());
535 };
536 let total_amount = paths
537 .iter()
538 .map(|path| path.amount_in.clone())
539 .sum();
540 Ok(execute_split_plan(
541 paths,
542 market,
543 &root_hop.descriptor.token_in.address,
544 &total_amount,
545 &MarketOverrides::empty(),
546 StateCapture::Skip,
547 )?
548 .post_swap)
549}
550
551type HopKey = (ComponentId, Bytes, Bytes);
553
554fn hop_key(hop: &HopDescriptor) -> HopKey {
555 (hop.component_id.clone(), hop.token_in.address.clone(), hop.token_out.address.clone())
556}
557
558struct SplitSwap {
559 hop: HopDescriptor,
560 split: f64,
561 amount_in: BigUint,
562}
563
564struct SimulatedSplitSwap {
566 hop: HopDescriptor,
567 split: f64,
568 amount_in: BigUint,
569 amount_out: BigUint,
570 gas: BigUint,
571 pre_swap_state: Option<Box<dyn ProtocolSim>>,
575}
576
577#[derive(Clone, Copy)]
583enum StateCapture {
584 Skip,
586 Keep,
588}
589
590struct SplitExecution {
592 swaps: Vec<SimulatedSplitSwap>,
593 available: FxHashMap<Bytes, BigUint>,
594 post_swap: MarketOverrides,
595 total_gas: u64,
596}
597
598fn merge_shared_hops(paths: &[PathAllocation]) -> FxHashMap<Bytes, Vec<SplitSwap>> {
602 let mut hops: FxHashMap<HopKey, SplitSwap> = FxHashMap::default();
603
604 for path in paths {
605 for hop in &path.hops {
606 let desc = &hop.descriptor;
607 hops.entry(hop_key(desc))
608 .or_insert(SplitSwap {
609 hop: HopDescriptor::new(
610 desc.component_id.clone(),
611 desc.token_in.clone(),
612 desc.token_out.clone(),
613 ),
614 split: 0.0,
617 amount_in: BigUint::ZERO,
618 });
619 }
620 }
621
622 let mut branch_collections: FxHashMap<Bytes, Vec<SplitSwap>> = FxHashMap::default();
623 for (_, swap) in hops {
624 branch_collections
625 .entry(swap.hop.token_in.address.clone())
626 .or_default()
627 .push(swap);
628 }
629 for branch_collection in branch_collections.values_mut() {
632 branch_collection.sort_by(|a, b| {
633 a.hop
634 .component_id
635 .cmp(&b.hop.component_id)
636 .then_with(|| {
637 a.hop
638 .token_in
639 .address
640 .cmp(&b.hop.token_in.address)
641 })
642 .then_with(|| {
643 a.hop
644 .token_out
645 .address
646 .cmp(&b.hop.token_out.address)
647 })
648 });
649 }
650 branch_collections
651}
652
653fn splits_from_amounts(
676 mut hops: Vec<SplitSwap>,
677 total_available: &BigUint,
678) -> Result<Vec<SplitSwap>, AlgorithmError> {
679 hops.sort_by(|left, right| right.amount_in.cmp(&left.amount_in));
684
685 let last = hops.len().saturating_sub(1);
686 let fractions: Vec<f64> = hops
687 .iter()
688 .enumerate()
689 .map(|(ix, swap)| if ix == last { 0.0 } else { share_of(&swap.amount_in, total_available) })
690 .collect();
691
692 let amounts = fractions_to_amounts(total_available, &fractions).map_err(|error| {
693 AlgorithmError::Other(format!(
694 "cannot divide the {total_available} standing at this token between {} swaps: {error}",
695 fractions.len(),
696 ))
697 })?;
698 for ((swap, split), amount) in hops
699 .iter_mut()
700 .zip(fractions)
701 .zip(amounts)
702 {
703 swap.split = split;
704 swap.amount_in = amount;
705 }
706 Ok(hops)
707}
708
709fn share_of(part: &BigUint, total: &BigUint) -> f64 {
711 match (part.to_f64(), total.to_f64()) {
712 (Some(part), Some(total)) if total > 0.0 => part / total,
713 _ => 0.0,
714 }
715}
716
717fn share_output(output: &BigUint, fed_amounts: &[BigUint]) -> Vec<BigUint> {
723 let total: BigUint = fed_amounts.iter().sum();
724 if total.is_zero() {
725 return vec![BigUint::ZERO; fed_amounts.len()];
726 }
727 let mut shares: Vec<BigUint> = fed_amounts
728 .iter()
729 .map(|fed| output * fed / &total)
730 .collect();
731 let assigned: BigUint = shares.iter().sum();
732 if let Some(last) = shares.last_mut() {
733 *last += output - assigned;
734 }
735 shares
736}
737
738fn build_in_degree(hops_by_token: &FxHashMap<Bytes, Vec<SplitSwap>>) -> FxHashMap<Bytes, usize> {
741 let mut in_degree: FxHashMap<Bytes, usize> = FxHashMap::default();
742 for (token_in_addr, branch_collection) in hops_by_token {
743 in_degree
744 .entry(token_in_addr.clone())
745 .or_insert(0);
746 for swap in branch_collection {
747 *in_degree
748 .entry(swap.hop.token_out.address.clone())
749 .or_insert(0) += 1;
750 }
751 }
752 in_degree
753}
754
755struct PathLedger {
761 amount_in: Vec<BigUint>,
762 next_hop_ix: Vec<usize>,
763}
764
765impl PathLedger {
766 fn new(paths: &[PathAllocation]) -> Result<Self, AlgorithmError> {
782 let amount_in: Vec<BigUint> = paths
783 .iter()
784 .map(|path| path.amount_in.clone())
785 .collect();
786 if amount_in.iter().all(BigUint::is_zero) {
787 return Err(AlgorithmError::Other(
788 "cannot divide the order across these paths: every path carries a zero amount"
789 .to_string(),
790 ));
791 }
792 Ok(Self { next_hop_ix: vec![0; paths.len()], amount_in })
793 }
794
795 fn standing_at(
800 &self,
801 paths: &[PathAllocation],
802 token: &Bytes,
803 ) -> FxHashMap<HopKey, Vec<usize>> {
804 let mut by_hop: FxHashMap<HopKey, Vec<usize>> = FxHashMap::default();
805 for (path_ix, path) in paths.iter().enumerate() {
806 let Some(hop) = path.hops.get(self.next_hop_ix[path_ix]) else {
807 continue;
808 };
809 if hop.descriptor.token_in.address == *token {
810 by_hop
811 .entry(hop_key(&hop.descriptor))
812 .or_default()
813 .push(path_ix);
814 }
815 }
816 by_hop
817 }
818
819 fn fed_amounts(&self, fed: &[usize]) -> Vec<BigUint> {
821 fed.iter()
822 .map(|&path_ix| self.amount_in[path_ix].clone())
823 .collect()
824 }
825
826 fn fed_total(&self, fed: &[usize]) -> BigUint {
828 let mut total = BigUint::ZERO;
829 for &path_ix in fed {
830 total += &self.amount_in[path_ix];
831 }
832 total
833 }
834
835 fn rescale(&mut self, fed: &[usize], total: &BigUint) {
837 let fed_amounts = self.fed_amounts(fed);
838 for (&path_ix, share) in fed
839 .iter()
840 .zip(share_output(total, &fed_amounts))
841 {
842 self.amount_in[path_ix] = share;
843 }
844 }
845
846 fn advance(&mut self, fed: &[usize]) {
848 for &path_ix in fed {
849 self.next_hop_ix[path_ix] += 1;
850 }
851 }
852}
853
854struct TokenBalances(FxHashMap<Bytes, BigUint>);
856
857impl TokenBalances {
858 fn starting(token: &Bytes, amount: &BigUint) -> Self {
859 Self(FxHashMap::from_iter([(token.clone(), amount.clone())]))
860 }
861
862 fn at(&self, token: &Bytes) -> BigUint {
864 self.0
865 .get(token)
866 .cloned()
867 .unwrap_or_default()
868 }
869
870 fn spend(
882 &mut self,
883 token: &Token,
884 component_id: &ComponentId,
885 amount: &BigUint,
886 ) -> Result<(), AlgorithmError> {
887 let standing = self
888 .0
889 .entry(token.address.clone())
890 .or_default();
891 if *standing < *amount {
892 return Err(AlgorithmError::Other(format!(
893 "the swap through {component_id} spends {amount} {}, more than the {standing} \
894 standing at it",
895 token.symbol,
896 )));
897 }
898 *standing -= amount;
899 Ok(())
900 }
901
902 fn credit(&mut self, token: &Bytes, amount: &BigUint) {
903 *self.0.entry(token.clone()).or_default() += amount;
904 }
905
906 fn into_inner(self) -> FxHashMap<Bytes, BigUint> {
907 self.0
908 }
909}
910
911fn amounts_for_branch(
924 branch: Vec<SplitSwap>,
925 standing: &FxHashMap<HopKey, Vec<usize>>,
926 ledger: &PathLedger,
927 total: &BigUint,
928) -> Result<Vec<(SplitSwap, Vec<usize>)>, AlgorithmError> {
929 let sized: Vec<SplitSwap> = branch
930 .into_iter()
931 .map(|mut split_swap| {
932 let fed = standing
933 .get(&hop_key(&split_swap.hop))
934 .ok_or_else(|| {
935 AlgorithmError::Other(format!(
936 "no path feeds the swap through {} at this point in the plan",
937 split_swap.hop.component_id,
938 ))
939 })?;
940 split_swap.amount_in = ledger.fed_total(fed);
941 Ok(split_swap)
942 })
943 .collect::<Result<_, AlgorithmError>>()?;
944
945 Ok(splits_from_amounts(sized, total)?
947 .into_iter()
948 .map(|split_swap| {
949 let fed = standing
950 .get(&hop_key(&split_swap.hop))
951 .cloned()
952 .unwrap_or_default();
953 (split_swap, fed)
954 })
955 .collect())
956}
957
958fn run_merged_swap(
967 swap: SplitSwap,
968 market: &MarketState,
969 base_overrides: &MarketOverrides,
970 post_swap: &MarketOverrides,
971 capture: StateCapture,
972) -> Result<(SimulatedSplitSwap, Box<dyn ProtocolSim>), AlgorithmError> {
973 let sim = post_swap
974 .get(&swap.hop.component_id)
975 .or_else(|| base_overrides.get(&swap.hop.component_id))
976 .or_else(|| market.get_simulation_state(&swap.hop.component_id))
977 .ok_or_else(|| AlgorithmError::DataNotFound {
978 kind: "simulation state",
979 id: Some(swap.hop.component_id.clone()),
980 })?;
981
982 let result = sim
983 .get_amount_out_metered(
984 &swap.hop.component_id,
985 SPLIT_PLAN_STAGE,
986 swap.amount_in.clone(),
987 &swap.hop.token_in,
988 &swap.hop.token_out,
989 )
990 .map_err(|e| AlgorithmError::SimulationFailed {
991 component_id: swap.hop.component_id.clone(),
992 error: e.to_string(),
993 })?;
994
995 let executed = SimulatedSplitSwap {
996 hop: swap.hop,
997 split: swap.split,
998 amount_in: swap.amount_in,
999 amount_out: result.amount,
1000 gas: result.gas,
1001 pre_swap_state: match capture {
1002 StateCapture::Keep => Some(sim.clone_box()),
1003 StateCapture::Skip => None,
1004 },
1005 };
1006 Ok((executed, result.new_state))
1007}
1008
1009fn execute_split_plan(
1025 paths: &[PathAllocation],
1026 market: &MarketState,
1027 start_token: &Bytes,
1028 start_amount: &BigUint,
1029 base_overrides: &MarketOverrides,
1030 capture: StateCapture,
1031) -> Result<SplitExecution, AlgorithmError> {
1032 for path in paths {
1033 path.validate_token_cycles()?;
1034 }
1035
1036 let mut hops_by_token = merge_shared_hops(paths);
1037 let mut in_degree = build_in_degree(&hops_by_token);
1038 let mut ready = VecDeque::from([start_token.clone()]);
1039
1040 let mut ledger = PathLedger::new(paths)?;
1041 let mut balances = TokenBalances::starting(start_token, start_amount);
1042 let mut swaps = Vec::new();
1043 let mut post_swap = MarketOverrides::empty();
1044 let mut total_gas: u64 = 0;
1045
1046 while let Some(token_addr) = ready.pop_front() {
1047 let Some(branch) = hops_by_token.remove(&token_addr) else {
1048 continue;
1049 };
1050 let standing = ledger.standing_at(paths, &token_addr);
1051 let sized = amounts_for_branch(branch, &standing, &ledger, &balances.at(&token_addr))?;
1052
1053 for (split_swap, fed) in &sized {
1057 ledger.rescale(fed, &split_swap.amount_in);
1058 }
1059
1060 for (split_swap, fed) in sized {
1061 let token_in = split_swap.hop.token_in.clone();
1062 let token_out = split_swap.hop.token_out.address.clone();
1063 let component_id = split_swap.hop.component_id.clone();
1064
1065 balances.spend(&token_in, &component_id, &split_swap.amount_in)?;
1066 let (executed, new_state) =
1067 run_merged_swap(split_swap, market, base_overrides, &post_swap, capture)?;
1068 balances.credit(&token_out, &executed.amount_out);
1069
1070 ledger.rescale(&fed, &executed.amount_out);
1074 ledger.advance(&fed);
1075
1076 total_gas = total_gas.saturating_add(
1077 executed
1078 .gas
1079 .to_u64()
1080 .unwrap_or(u64::MAX),
1081 );
1082 swaps.push(executed);
1083 post_swap = post_swap.with_override(component_id, new_state);
1084
1085 if let Some(deg) = in_degree.get_mut(&token_out) {
1087 *deg = deg.saturating_sub(1);
1088 if *deg == 0 {
1089 ready.push_back(token_out);
1090 }
1091 }
1092 }
1093 }
1094
1095 if !hops_by_token.is_empty() {
1096 let stuck: Vec<_> = hops_by_token
1097 .keys()
1098 .map(|k| format!("{k}"))
1099 .collect();
1100 return Err(AlgorithmError::Other(format!(
1101 "dependency cycle — unprocessed tokens: [{}]",
1102 stuck.join(", "),
1103 )));
1104 }
1105
1106 Ok(SplitExecution { swaps, available: balances.into_inner(), post_swap, total_gas })
1107}
1108
1109fn allocations_from_descriptors(
1116 paths: &[&[HopDescriptor]],
1117 fractions: &[f64],
1118 total_amount: &BigUint,
1119) -> Result<Vec<PathAllocation>, AlgorithmError> {
1120 if paths.len() != fractions.len() {
1121 return Err(AlgorithmError::Other(format!(
1122 "paths/fractions length mismatch: {} paths, {} fractions",
1123 paths.len(),
1124 fractions.len(),
1125 )));
1126 }
1127 let amounts = fractions_to_amounts(total_amount, fractions)
1128 .map_err(|e| AlgorithmError::Other(e.to_string()))?;
1129
1130 paths
1131 .iter()
1132 .zip(fractions.iter())
1133 .zip(amounts)
1134 .map(|((path, &flow_fraction), amount_in)| {
1135 if path.is_empty() {
1136 return Err(AlgorithmError::Other("path has no hops".to_string()));
1137 }
1138 Ok(PathAllocation {
1139 hops: path
1140 .iter()
1141 .cloned()
1142 .map(|descriptor| SimulatedHop {
1143 descriptor,
1144 amount_out: BigUint::ZERO,
1145 gas: BigUint::ZERO,
1146 })
1147 .collect(),
1148 flow_fraction,
1149 amount_in,
1150 amount_out: BigUint::ZERO,
1151 marginal_price_product: 0.0,
1152 })
1153 })
1154 .collect()
1155}
1156
1157pub fn evaluate_total_output(
1163 paths: &[&[HopDescriptor]],
1164 fractions: &[f64],
1165 total_amount: &BigUint,
1166 market: &MarketState,
1167 overrides: &MarketOverrides,
1168) -> Result<(BigUint, u64), AlgorithmError> {
1169 let first_hop = paths
1170 .first()
1171 .and_then(|path| path.first())
1172 .ok_or_else(|| AlgorithmError::Other("paths must not be empty".to_string()))?;
1173 let terminal_tokens: FxHashSet<Bytes> = paths
1174 .iter()
1175 .map(|path| {
1176 path.last()
1177 .map(|hop| hop.token_out.address.clone())
1178 .ok_or_else(|| AlgorithmError::Other("path has no hops".to_string()))
1179 })
1180 .collect::<Result<_, AlgorithmError>>()?;
1181 let allocations = allocations_from_descriptors(paths, fractions, total_amount)?;
1182 let execution = execute_split_plan(
1183 &allocations,
1184 market,
1185 &first_hop.token_in.address,
1186 total_amount,
1187 overrides,
1188 StateCapture::Skip,
1189 )?;
1190 let total_out = terminal_tokens
1191 .iter()
1192 .map(|token| {
1193 execution
1194 .available
1195 .get(token)
1196 .cloned()
1197 .unwrap_or_default()
1198 })
1199 .sum();
1200 Ok((total_out, execution.total_gas))
1201}
1202
1203pub fn build_split_route(
1253 paths: &[PathAllocation],
1254 market: &MarketState,
1255 order: &Order,
1256) -> Result<Route, AlgorithmError> {
1257 let execution = execute_split_plan(
1258 paths,
1259 market,
1260 order.token_in(),
1261 order.amount(),
1262 &MarketOverrides::empty(),
1263 StateCapture::Keep,
1264 )?;
1265 let mut swaps = Vec::new();
1266 let mut route_tokens: FxHashMap<Bytes, Token> = FxHashMap::default();
1267
1268 for mut executed in execution.swaps {
1269 let component = market
1270 .get_component(&executed.hop.component_id)
1271 .ok_or_else(|| AlgorithmError::DataNotFound {
1272 kind: "protocol component",
1273 id: Some(executed.hop.component_id.clone()),
1274 })?;
1275
1276 let pre_swap_state = executed
1277 .pre_swap_state
1278 .take()
1279 .ok_or_else(|| {
1280 AlgorithmError::Other(format!(
1281 "the plan kept no state for the swap through {}",
1282 executed.hop.component_id,
1283 ))
1284 })?;
1285
1286 let in_addr = executed.hop.token_in.address.clone();
1287 let out_addr = executed.hop.token_out.address.clone();
1288 swaps.push(
1289 Swap::new(
1290 executed.hop.component_id,
1291 component.protocol_system.clone(),
1292 in_addr.clone(),
1293 out_addr.clone(),
1294 executed.amount_in,
1295 executed.amount_out,
1296 executed.gas,
1297 component.clone(),
1298 pre_swap_state,
1299 )
1300 .with_split(executed.split),
1301 );
1302 route_tokens
1303 .entry(in_addr)
1304 .or_insert(executed.hop.token_in);
1305 route_tokens
1306 .entry(out_addr.clone())
1307 .or_insert(executed.hop.token_out);
1308 }
1309
1310 Ok(Route::new(swaps, route_tokens)?)
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use num_bigint::BigInt;
1316 use rstest::rstest;
1317
1318 use super::*;
1319 use crate::{
1320 algorithm::test_utils::{
1321 component, order, token, ConstantProductSim, DivByZeroSim, MockProtocolSim,
1322 },
1323 types::OrderSide,
1324 };
1325
1326 fn make_market(components: Vec<(&str, Vec<Token>, Box<dyn ProtocolSim>)>) -> MarketState {
1327 let mut market = MarketState::new();
1328 for (component_id, tokens, sim) in components {
1329 market.upsert_components(std::iter::once(component(component_id, &tokens)));
1330 market.update_states([(component_id.to_string(), sim)]);
1331 market.upsert_tokens(tokens);
1332 }
1333 market
1334 }
1335
1336 #[test]
1337 fn test_split_amount_exact_sum() {
1338 let total = BigUint::from(1_000_000_000_000_000_000_u64);
1339 for fraction in [0.1, 0.5, 0.9, 0.999] {
1340 let (part, remainder) = split_amount(&total, fraction);
1341 assert_eq!(
1342 &part + &remainder,
1343 total,
1344 "part + remainder must equal total for fraction={fraction}"
1345 );
1346 }
1347 }
1348
1349 #[test]
1350 fn test_split_amount_edge_fraction_zero() {
1351 let total = BigUint::from(1_000_000_000_000_000_000_u64);
1352 let (part, remainder) = split_amount(&total, 0.0);
1353 assert!(part.is_zero());
1354 assert_eq!(remainder, total);
1355 }
1356
1357 #[test]
1358 fn test_split_amount_clamps_above_one() {
1359 let total = BigUint::from(1_000_000_000_000_000_000_u64);
1360 let (part, remainder) = split_amount(&total, 1.5);
1361 assert_eq!(part, total);
1362 assert!(remainder.is_zero());
1363 }
1364
1365 #[test]
1366 fn test_split_amount_clamps_negative() {
1367 let total = BigUint::from(1_000_000_000_000_000_000_u64);
1368 let (part, remainder) = split_amount(&total, -0.5);
1369 assert!(part.is_zero());
1370 assert_eq!(remainder, total);
1371 }
1372
1373 #[test]
1374 fn test_fractions_to_amounts_exact_sum() {
1375 let total = BigUint::from(999_999_999_999_999_999_u64);
1376 let fractions = [0.3, 0.5, 0.2];
1377 let amounts = fractions_to_amounts(&total, &fractions).unwrap();
1378 assert_eq!(amounts.len(), 3);
1379 let sum: BigUint = amounts.iter().sum();
1380 assert_eq!(sum, total, "amounts must sum exactly to total");
1381 }
1382
1383 #[test]
1384 fn test_fractions_to_amounts_empty() {
1385 let total = BigUint::from(1_000_u64);
1386 let err = fractions_to_amounts(&total, &[]).unwrap_err();
1387 assert_eq!(err, SplitMathError::EmptyFractions);
1388 }
1389
1390 #[test]
1391 fn test_fractions_to_amounts_exceeds_total() {
1392 let total = BigUint::from(1_000_u64);
1393 let err = fractions_to_amounts(&total, &[0.6, 0.6, 0.0]).unwrap_err();
1394 assert_eq!(
1395 err,
1396 SplitMathError::ExceedsTotal {
1397 asked: BigUint::from(1_200_u64),
1398 total: BigUint::from(1_000_u64),
1399 }
1400 );
1401 }
1402
1403 #[rstest]
1404 #[case::already_normalized(&[0.3, 0.5, 0.2])]
1405 #[case::drift(&[0.33, 0.33, 0.33])]
1406 fn test_normalize_fractions(#[case] input: &[f64]) {
1407 let mut fractions = input.to_vec();
1408 normalize_fractions(&mut fractions).unwrap();
1409 let sum: f64 = fractions.iter().sum();
1410 assert!((sum - 1.0).abs() < f64::EPSILON);
1411 }
1412
1413 #[rstest]
1414 #[case::empty(&[], SplitMathError::EmptyFractions)]
1415 #[case::all_zeros(&[0.0, 0.0, 0.0], SplitMathError::AllZeroFractions)]
1416 #[case::negative(&[-0.5, 0.5], SplitMathError::NegativeFraction)]
1417 fn test_normalize_fractions_invalid(#[case] input: &[f64], #[case] expected: SplitMathError) {
1418 let mut fractions = input.to_vec();
1419 let err = normalize_fractions(&mut fractions).unwrap_err();
1420 assert_eq!(err, expected);
1421 }
1422
1423 #[test]
1424 fn test_golden_section_finds_maximum() {
1425 let f = |x: f64| -(x - 0.3) * (x - 0.3);
1427 let result = golden_section_search(f, 0.0, 1.0, 100);
1428 assert!((result - 0.3).abs() < 1e-4, "expected ~0.3, got {result}");
1429 }
1430
1431 #[test]
1434 fn test_validate_token_cycles_valid_path() {
1435 let gas = BigUint::from(50_000u64);
1436 let path = PathAllocation {
1437 hops: vec![
1438 HopDescriptor::new("p1".to_string(), token(0x01, "A"), token(0x02, "B"))
1439 .with_amounts(BigUint::from(100u64), gas.clone()),
1440 HopDescriptor::new("p2".to_string(), token(0x02, "B"), token(0x03, "C"))
1441 .with_amounts(BigUint::from(100u64), gas),
1442 ],
1443 flow_fraction: 1.0,
1444 amount_in: BigUint::from(100u64),
1445 amount_out: BigUint::from(100u64),
1446 marginal_price_product: 1.0,
1447 };
1448 assert!(path.validate_token_cycles().is_ok());
1449 }
1450
1451 #[test]
1452 fn test_validate_token_cycles_empty_hops() {
1453 let path = PathAllocation {
1454 hops: vec![],
1455 flow_fraction: 1.0,
1456 amount_in: BigUint::from(100u64),
1457 amount_out: BigUint::from(100u64),
1458 marginal_price_product: 1.0,
1459 };
1460 assert!(path.validate_token_cycles().is_err());
1461 }
1462
1463 #[test]
1464 fn test_validate_token_cycles_valid_round_trip() {
1465 let gas = BigUint::from(50_000u64);
1467 let path = PathAllocation {
1468 hops: vec![
1469 HopDescriptor::new("p1".to_string(), token(0x01, "A"), token(0x02, "B"))
1470 .with_amounts(BigUint::from(100u64), gas.clone()),
1471 HopDescriptor::new("p2".to_string(), token(0x02, "B"), token(0x01, "A"))
1472 .with_amounts(BigUint::from(100u64), gas),
1473 ],
1474 flow_fraction: 1.0,
1475 amount_in: BigUint::from(100u64),
1476 amount_out: BigUint::from(100u64),
1477 marginal_price_product: 1.0,
1478 };
1479 assert!(path.validate_token_cycles().is_ok());
1480 }
1481
1482 #[test]
1483 fn test_validate_token_cycles_rejects_mid_path_cycle() {
1484 let gas = BigUint::from(50_000u64);
1487 let path = PathAllocation {
1488 hops: vec![
1489 HopDescriptor::new("p1".to_string(), token(0x01, "A"), token(0x02, "B"))
1490 .with_amounts(BigUint::from(100u64), gas.clone()),
1491 HopDescriptor::new("p2".to_string(), token(0x02, "B"), token(0x03, "C"))
1492 .with_amounts(BigUint::from(100u64), gas.clone()),
1493 HopDescriptor::new("p3".to_string(), token(0x03, "C"), token(0x01, "A"))
1494 .with_amounts(BigUint::from(100u64), gas.clone()),
1495 HopDescriptor::new("p4".to_string(), token(0x01, "A"), token(0x04, "D"))
1496 .with_amounts(BigUint::from(100u64), gas),
1497 ],
1498 flow_fraction: 1.0,
1499 amount_in: BigUint::from(100u64),
1500 amount_out: BigUint::from(100u64),
1501 marginal_price_product: 1.0,
1502 };
1503 assert!(path.validate_token_cycles().is_err());
1504 }
1505
1506 #[test]
1507 fn test_validate_token_cycles_rejects_intermediate_revisit() {
1508 let gas = BigUint::from(50_000u64);
1510 let path = PathAllocation {
1511 hops: vec![
1512 HopDescriptor::new("p1".to_string(), token(0x01, "A"), token(0x02, "B"))
1513 .with_amounts(BigUint::from(100u64), gas.clone()),
1514 HopDescriptor::new("p2".to_string(), token(0x02, "B"), token(0x03, "C"))
1515 .with_amounts(BigUint::from(100u64), gas.clone()),
1516 HopDescriptor::new("p3".to_string(), token(0x03, "C"), token(0x02, "B"))
1517 .with_amounts(BigUint::from(100u64), gas.clone()),
1518 HopDescriptor::new("p4".to_string(), token(0x02, "B"), token(0x04, "D"))
1519 .with_amounts(BigUint::from(100u64), gas),
1520 ],
1521 flow_fraction: 1.0,
1522 amount_in: BigUint::from(100u64),
1523 amount_out: BigUint::from(100u64),
1524 marginal_price_product: 1.0,
1525 };
1526 assert!(path.validate_token_cycles().is_err());
1527 }
1528
1529 #[test]
1532 fn test_compute_marginal_price_product_single_hop() {
1533 let token_a = token(0x0A, "A");
1534 let token_b = token(0x0B, "B");
1535 let market = make_market(vec![(
1536 "component_ab",
1537 vec![token_a.clone(), token_b.clone()],
1538 Box::new(MockProtocolSim::new(3.0)),
1539 )]);
1540
1541 let hops = [HopDescriptor::new("component_ab".to_string(), token_a, token_b)];
1542
1543 let product =
1544 compute_marginal_price_product(&hops, &market, &MarketOverrides::empty()).unwrap();
1545 assert!((product - 3.0).abs() < f64::EPSILON, "expected 3.0, got {product}");
1546 }
1547
1548 #[test]
1549 fn test_compute_marginal_price_product_multi_hop() {
1550 let token_a = token(0x0A, "A");
1551 let token_b = token(0x0B, "B");
1552 let token_c = token(0x0C, "C");
1553 let market = make_market(vec![
1554 (
1555 "component_ab",
1556 vec![token_a.clone(), token_b.clone()],
1557 Box::new(MockProtocolSim::new(2.0)),
1558 ),
1559 (
1560 "component_bc",
1561 vec![token_b.clone(), token_c.clone()],
1562 Box::new(MockProtocolSim::new(4.0)),
1563 ),
1564 ]);
1565
1566 let hops = [
1567 HopDescriptor::new("component_ab".to_string(), token_a, token_b.clone()),
1568 HopDescriptor::new("component_bc".to_string(), token_b, token_c),
1569 ];
1570
1571 let product =
1572 compute_marginal_price_product(&hops, &market, &MarketOverrides::empty()).unwrap();
1573 assert!((product - 8.0).abs() < f64::EPSILON, "expected 8.0, got {product}");
1575 }
1576
1577 #[test]
1578 fn test_compute_marginal_price_product_uses_overrides() {
1579 let token_a = token(0x0A, "A");
1580 let token_b = token(0x0B, "B");
1581 let market = make_market(vec![(
1582 "component_ab",
1583 vec![token_a.clone(), token_b.clone()],
1584 Box::new(MockProtocolSim::new(3.0)),
1585 )]);
1586
1587 let hops = [HopDescriptor::new("component_ab".to_string(), token_a, token_b)];
1588
1589 let overrides = MarketOverrides::empty()
1591 .with_override("component_ab".to_string(), Box::new(MockProtocolSim::new(7.0)));
1592
1593 let product = compute_marginal_price_product(&hops, &market, &overrides).unwrap();
1594 assert!((product - 7.0).abs() < f64::EPSILON, "expected 7.0, got {product}");
1595 }
1596
1597 #[test]
1598 fn test_simulate_path_correct_output() {
1599 let token_a = token(0x0A, "A");
1602 let token_b = token(0x0B, "B");
1603 let token_c = token(0x0C, "C");
1604 let market = make_market(vec![
1605 (
1606 "component_ab",
1607 vec![token_a.clone(), token_b.clone()],
1608 Box::new(MockProtocolSim::new(2.0)),
1609 ),
1610 (
1611 "component_bc",
1612 vec![token_b.clone(), token_c.clone()],
1613 Box::new(MockProtocolSim::new(3.0)),
1614 ),
1615 ]);
1616
1617 let hops = [
1618 HopDescriptor::new("component_ab".to_string(), token_a, token_b.clone()),
1619 HopDescriptor::new("component_bc".to_string(), token_b, token_c),
1620 ];
1621
1622 let amount_in = BigUint::from(1000u64);
1623 let overrides = MarketOverrides::empty();
1624 let result = simulate_path(&hops, &amount_in, &market, &overrides).unwrap();
1625
1626 assert_eq!(result.amount_out, BigUint::from(6000u64));
1627
1628 assert!(
1630 (result.marginal_price_product - 6.0).abs() < f64::EPSILON,
1631 "expected marginal_price_product 6.0, got {}",
1632 result.marginal_price_product
1633 );
1634 }
1635
1636 #[test]
1637 fn test_simulate_path_contains_simulation_panic() {
1638 let token_a = token(0x0A, "A");
1641 let token_b = token(0x0B, "B");
1642 let market = make_market(vec![(
1643 "component_ab",
1644 vec![token_a.clone(), token_b.clone()],
1645 Box::new(DivByZeroSim::default()),
1646 )]);
1647
1648 let hops = [HopDescriptor::new("component_ab".to_string(), token_a, token_b)];
1649 let result =
1650 simulate_path(&hops, &BigUint::from(1000u64), &market, &MarketOverrides::empty());
1651
1652 match result {
1653 Err(AlgorithmError::SimulationFailed { component_id, error }) => {
1654 assert_eq!(component_id, "component_ab");
1655 assert!(error.contains("panic"), "error should mention the panic: {error}");
1656 }
1657 Err(other) => panic!("expected SimulationFailed, got {other:?}"),
1658 Ok(_) => panic!("expected SimulationFailed, got Ok"),
1659 }
1660 }
1661
1662 #[test]
1663 fn test_market_overrides_with_zero_gas() {
1664 let token_a = token(0x0A, "A");
1665 let token_b = token(0x0B, "B");
1666 let token_c = token(0x0C, "C");
1667 let sim_ab = MockProtocolSim::new(2.0).with_gas(100_000);
1668 let sim_bc = MockProtocolSim::new(3.0).with_gas(70_000);
1669 let market = make_market(vec![
1670 ("component_ab", vec![token_a.clone(), token_b.clone()], Box::new(sim_ab.clone())),
1671 ("component_bc", vec![token_b.clone(), token_c.clone()], Box::new(sim_bc.clone())),
1672 ]);
1673
1674 let overrides = MarketOverrides::empty()
1676 .with_override("component_ab".to_string(), Box::new(sim_ab))
1677 .with_zero_gas(
1678 "component_ab".to_string(),
1679 token_a.address.clone(),
1680 token_b.address.clone(),
1681 )
1682 .with_override("component_bc".to_string(), Box::new(sim_bc));
1683
1684 let hops_ab =
1685 [HopDescriptor::new("component_ab".to_string(), token_a.clone(), token_b.clone())];
1686 let hops_bc = [HopDescriptor::new("component_bc".to_string(), token_b, token_c)];
1687 let amount_in = BigUint::from(1000u64);
1688
1689 let hop_gas_sum = |sim: &SimResult| -> BigUint {
1690 sim.hop_results
1691 .iter()
1692 .map(|(_, gas)| gas)
1693 .sum()
1694 };
1695
1696 let normal_ab =
1697 simulate_path(&hops_ab, &amount_in, &market, &MarketOverrides::empty()).unwrap();
1698 let zero_gas_ab = simulate_path(&hops_ab, &amount_in, &market, &overrides).unwrap();
1699
1700 assert_eq!(normal_ab.amount_out, zero_gas_ab.amount_out);
1701 assert!(hop_gas_sum(&normal_ab) > BigUint::ZERO, "normal gas should be non-zero");
1702 assert_eq!(
1703 hop_gas_sum(&zero_gas_ab),
1704 BigUint::ZERO,
1705 "zero-gas override should report gas=0"
1706 );
1707
1708 let result_bc = simulate_path(&hops_bc, &amount_in, &market, &overrides).unwrap();
1710 assert_eq!(
1711 hop_gas_sum(&result_bc),
1712 BigUint::from(70_000u64),
1713 "non-zero-gas override should keep its gas"
1714 );
1715 }
1716
1717 #[test]
1718 fn test_evaluate_total_output_two_paths() {
1719 let token_a = token(0x0A, "A");
1729 let token_b = token(0x0B, "B");
1730 let market = make_market(vec![
1731 (
1732 "component_1",
1733 vec![token_a.clone(), token_b.clone()],
1734 Box::new(MockProtocolSim::new(2.0).with_gas(50_000)),
1735 ),
1736 (
1737 "component_2",
1738 vec![token_a.clone(), token_b.clone()],
1739 Box::new(MockProtocolSim::new(3.0).with_gas(60_000)),
1740 ),
1741 ]);
1742
1743 let hops_1 =
1744 [HopDescriptor::new("component_1".to_string(), token_a.clone(), token_b.clone())];
1745 let hops_2 = [HopDescriptor::new("component_2".to_string(), token_a, token_b)];
1746
1747 let paths: Vec<&[HopDescriptor]> = vec![&hops_1, &hops_2];
1748 let fractions = [0.5, 0.5];
1749 let total_amount = BigUint::from(1000u64);
1750 let overrides = MarketOverrides::empty();
1751
1752 let (total_out, total_gas) =
1753 evaluate_total_output(&paths, &fractions, &total_amount, &market, &overrides).unwrap();
1754
1755 assert_eq!(total_out, BigUint::from(2500u64));
1756 assert_eq!(total_gas, 110_000);
1757 }
1758
1759 #[test]
1760 fn test_evaluate_total_output_shared_component_depletes() {
1761 let token_a = token(0x0A, "A");
1766 let token_b = token(0x0B, "B");
1767 let cp = ConstantProductSim {
1768 reserve_0: BigUint::from(10_000u64),
1769 reserve_1: BigUint::from(10_000u64),
1770 gas: 50_000,
1771 };
1772 let market = make_market(vec![(
1773 "component",
1774 vec![token_a.clone(), token_b.clone()],
1775 Box::new(cp.clone()),
1776 )]);
1777
1778 let hops_1 =
1779 [HopDescriptor::new("component".to_string(), token_a.clone(), token_b.clone())];
1780 let hops_2 =
1781 [HopDescriptor::new("component".to_string(), token_a.clone(), token_b.clone())];
1782 let paths: Vec<&[HopDescriptor]> = vec![&hops_1, &hops_2];
1783 let total_amount = BigUint::from(1000u64);
1784
1785 let (total_out, _) = evaluate_total_output(
1786 &paths,
1787 &[0.5, 0.5],
1788 &total_amount,
1789 &market,
1790 &MarketOverrides::empty(),
1791 )
1792 .unwrap();
1793
1794 let full_swap_out = cp
1795 .get_amount_out(total_amount, &token_a, &token_b)
1796 .unwrap()
1797 .amount;
1798 let half_fresh_out = cp
1799 .get_amount_out(BigUint::from(500u64), &token_a, &token_b)
1800 .unwrap()
1801 .amount;
1802
1803 assert!(
1806 total_out < &half_fresh_out * 2u32,
1807 "shared component must deplete between paths: {total_out} >= {}",
1808 &half_fresh_out * 2u32
1809 );
1810 let diff = BigInt::from(total_out) - BigInt::from(full_swap_out);
1811 assert!(
1812 diff.magnitude() <= &BigUint::from(2u32),
1813 "sequential split should match one full swap (±rounding), diff {diff}"
1814 );
1815 }
1816
1817 #[test]
1818 fn test_evaluate_total_output_gas_deduplication() {
1819 let token_a = token(0x0A, "A");
1829 let token_b = token(0x0B, "B");
1830 let token_c = token(0x0C, "C");
1831 let token_d = token(0x0D, "D");
1832 let market = make_market(vec![
1833 (
1834 "P1",
1835 vec![token_a.clone(), token_b.clone()],
1836 Box::new(MockProtocolSim::new(2.0).with_gas(100_000)),
1837 ),
1838 (
1839 "P2",
1840 vec![token_b.clone(), token_c.clone()],
1841 Box::new(MockProtocolSim::new(1.5).with_gas(50_000)),
1842 ),
1843 (
1844 "P3",
1845 vec![token_b.clone(), token_d.clone()],
1846 Box::new(MockProtocolSim::new(3.0).with_gas(70_000)),
1847 ),
1848 ]);
1849
1850 let hops_1 = [
1852 HopDescriptor::new("P1".to_string(), token_a.clone(), token_b.clone()),
1853 HopDescriptor::new("P2".to_string(), token_b.clone(), token_c),
1854 ];
1855 let hops_2 = [
1857 HopDescriptor::new("P1".to_string(), token_a, token_b.clone()),
1858 HopDescriptor::new("P3".to_string(), token_b, token_d),
1859 ];
1860
1861 let paths: Vec<&[HopDescriptor]> = vec![&hops_1, &hops_2];
1862 let fractions = [0.5, 0.5];
1863 let total_amount = BigUint::from(1000u64);
1864 let overrides = MarketOverrides::empty();
1865
1866 let (_, total_gas) =
1867 evaluate_total_output(&paths, &fractions, &total_amount, &market, &overrides).unwrap();
1868
1869 assert_eq!(total_gas, 220_000);
1871 }
1872
1873 #[test]
1874 fn test_evaluate_total_output_matches_route_order_for_same_component_branches() {
1875 let token_a = token(0x0A, "A");
1881 let token_b = token(0x0B, "B");
1882 let token_c = token(0x0C, "C");
1883 let token_d = token(0x0D, "D");
1884 let market = make_market(vec![
1885 (
1886 "tricomponent",
1887 vec![token_a.clone(), token_b.clone(), token_c.clone()],
1888 Box::new(MockProtocolSim::new(2.0).with_gas(80_000)),
1889 ),
1890 (
1891 "component_bd",
1892 vec![token_b.clone(), token_d.clone()],
1893 Box::new(MockProtocolSim::new(1.0)),
1894 ),
1895 (
1896 "component_cd",
1897 vec![token_c.clone(), token_d.clone()],
1898 Box::new(MockProtocolSim::new(1.0)),
1899 ),
1900 ]);
1901
1902 let hops_b = [
1903 HopDescriptor::new("tricomponent".to_string(), token_a.clone(), token_b.clone()),
1904 HopDescriptor::new("component_bd".to_string(), token_b.clone(), token_d.clone()),
1905 ];
1906 let hops_c = [
1907 HopDescriptor::new("tricomponent".to_string(), token_a.clone(), token_c.clone()),
1908 HopDescriptor::new("component_cd".to_string(), token_c.clone(), token_d.clone()),
1909 ];
1910
1911 let total_amount = BigUint::from(1000u64);
1912 let paths: Vec<&[HopDescriptor]> = vec![&hops_b, &hops_c];
1913 let fractions = [0.4, 0.6];
1914 let (total_out, total_gas) = evaluate_total_output(
1915 &paths,
1916 &fractions,
1917 &total_amount,
1918 &market,
1919 &MarketOverrides::empty(),
1920 )
1921 .unwrap();
1922
1923 let zero = BigUint::ZERO;
1924 let allocations = vec![
1925 PathAllocation {
1926 hops: hops_b
1927 .iter()
1928 .cloned()
1929 .map(|hop| hop.with_amounts(zero.clone(), zero.clone()))
1930 .collect(),
1931 flow_fraction: 0.4,
1932 amount_in: BigUint::from(400u64),
1933 amount_out: zero.clone(),
1934 marginal_price_product: 0.0,
1935 },
1936 PathAllocation {
1937 hops: hops_c
1938 .iter()
1939 .cloned()
1940 .map(|hop| hop.with_amounts(zero.clone(), zero.clone()))
1941 .collect(),
1942 flow_fraction: 0.6,
1943 amount_in: BigUint::from(600u64),
1944 amount_out: zero,
1945 marginal_price_product: 0.0,
1946 },
1947 ];
1948 let ord = order(&token_a, &token_d, 1000, OrderSide::Sell);
1949 let route = build_split_route(&allocations, &market, &ord).unwrap();
1950 let route_out: BigUint = route
1951 .swaps()
1952 .iter()
1953 .filter(|swap| swap.token_out() == &token_d.address)
1954 .map(|swap| swap.amount_out().clone())
1955 .sum();
1956
1957 assert_eq!(
1958 route.swaps()[0].token_out(),
1959 &token_c.address,
1960 "larger C branch should execute first"
1961 );
1962 assert_eq!(total_out, BigUint::from(2400u64));
1963 assert_eq!(route_out, total_out);
1964 assert_eq!(route.total_gas().to_u64().unwrap(), total_gas);
1965 }
1966
1967 #[test]
1968 fn test_gas_dedup_different_tokens() {
1969 let token_a = token(0x0A, "A");
1976 let token_b = token(0x0B, "B");
1977 let token_c = token(0x0C, "C");
1978 let market = make_market(vec![(
1979 "tricomponent",
1980 vec![token_a.clone(), token_b.clone(), token_c.clone()],
1981 Box::new(MockProtocolSim::new(1.0).with_gas(80_000)),
1982 )]);
1983
1984 let hops_1 = [HopDescriptor::new("tricomponent".to_string(), token_a, token_b.clone())];
1985 let hops_2 = [HopDescriptor::new("tricomponent".to_string(), token_b, token_c)];
1986
1987 let paths: Vec<&[HopDescriptor]> = vec![&hops_1, &hops_2];
1988 let fractions = [0.5, 0.5];
1989 let total_amount = BigUint::from(1000u64);
1990 let overrides = MarketOverrides::empty();
1991
1992 let (_, total_gas) =
1993 evaluate_total_output(&paths, &fractions, &total_amount, &market, &overrides).unwrap();
1994
1995 assert_eq!(total_gas, 160_000);
1997 }
1998
1999 #[test]
2000 fn test_build_post_swap_overrides_degrades_used_components() {
2001 let token_a = token(0x0A, "A");
2002 let token_b = token(0x0B, "B");
2003 let market = make_market(vec![(
2004 "component_ab",
2005 vec![token_a.clone(), token_b.clone()],
2006 Box::new(ConstantProductSim {
2007 reserve_0: BigUint::from(10_000u64),
2008 reserve_1: BigUint::from(20_000u64),
2009 gas: 50_000,
2010 }),
2011 )]);
2012
2013 let allocation = PathAllocation {
2014 hops: vec![SimulatedHop {
2015 descriptor: HopDescriptor::new(
2016 "component_ab".to_string(),
2017 token_a.clone(),
2018 token_b.clone(),
2019 ),
2020 amount_out: BigUint::from(1818u64),
2021 gas: BigUint::from(50_000u64),
2022 }],
2023 flow_fraction: 1.0,
2024 amount_in: BigUint::from(1000u64),
2025 amount_out: BigUint::from(1818u64),
2026 marginal_price_product: 2.0,
2027 };
2028
2029 let degraded = build_post_swap_overrides(&[allocation], &market).unwrap();
2030
2031 let probe = BigUint::from(100u64);
2034 let fresh_out = market
2035 .get_simulation_state("component_ab")
2036 .unwrap()
2037 .get_amount_out(probe.clone(), &token_a, &token_b)
2038 .unwrap()
2039 .amount;
2040 assert_eq!(fresh_out, BigUint::from(198u64));
2041
2042 let degraded_out = degraded
2046 .get(&"component_ab".to_string())
2047 .unwrap()
2048 .get_amount_out(probe, &token_a, &token_b)
2049 .unwrap()
2050 .amount;
2051 assert_eq!(degraded_out, BigUint::from(163u64));
2052 }
2053
2054 #[test]
2057 fn test_merge_shared_hops_combines_fractions() {
2058 let token_a = token(0x0A, "A");
2066 let token_b = token(0x0B, "B");
2067 let token_c = token(0x0C, "C");
2068
2069 let gas = BigUint::from(50_000u64);
2070 let paths = vec![
2071 PathAllocation {
2072 hops: vec![
2073 SimulatedHop {
2074 descriptor: HopDescriptor::new(
2075 "P1".to_string(),
2076 token_a.clone(),
2077 token_b.clone(),
2078 ),
2079 amount_out: BigUint::from(1200u64),
2080 gas: gas.clone(),
2081 },
2082 SimulatedHop {
2083 descriptor: HopDescriptor::new(
2084 "P2".to_string(),
2085 token_b.clone(),
2086 token_c.clone(),
2087 ),
2088 amount_out: BigUint::from(3600u64),
2089 gas: gas.clone(),
2090 },
2091 ],
2092 flow_fraction: 0.6,
2093 amount_in: BigUint::from(600u64),
2094 amount_out: BigUint::from(3600u64),
2095 marginal_price_product: 6.0,
2096 },
2097 PathAllocation {
2098 hops: vec![
2099 SimulatedHop {
2100 descriptor: HopDescriptor::new(
2101 "P1".to_string(),
2102 token_a.clone(),
2103 token_b.clone(),
2104 ),
2105 amount_out: BigUint::from(800u64),
2106 gas: gas.clone(),
2107 },
2108 SimulatedHop {
2109 descriptor: HopDescriptor::new(
2110 "P3".to_string(),
2111 token_b.clone(),
2112 token_c.clone(),
2113 ),
2114 amount_out: BigUint::from(1600u64),
2115 gas,
2116 },
2117 ],
2118 flow_fraction: 0.4,
2119 amount_in: BigUint::from(400u64),
2120 amount_out: BigUint::from(1600u64),
2121 marginal_price_product: 4.0,
2122 },
2123 ];
2124
2125 let hops_by_token = merge_shared_hops(&paths);
2126
2127 let branch_collection_a = &hops_by_token[&token_a.address];
2129 assert_eq!(branch_collection_a.len(), 1);
2130 assert_eq!(branch_collection_a[0].hop.component_id, "P1");
2131
2132 let branch_collection_b = &hops_by_token[&token_b.address];
2135 let at_b: Vec<&str> = branch_collection_b
2136 .iter()
2137 .map(|swap| swap.hop.component_id.as_str())
2138 .collect();
2139 assert_eq!(at_b, vec!["P2", "P3"]);
2140 assert!(
2141 branch_collection_b
2142 .iter()
2143 .chain(branch_collection_a)
2144 .all(|swap| swap.split == 0.0),
2145 "merging assigns no split; the amounts standing at the token decide it"
2146 );
2147 }
2148
2149 #[test]
2150 fn test_splits_from_amounts() {
2151 let token_a = token(0x0A, "A");
2152 let token_b = token(0x0B, "B");
2153
2154 let branch_collection = vec![
2155 SplitSwap {
2156 hop: HopDescriptor::new("component1".to_string(), token_a.clone(), token_b.clone()),
2157 split: 0.1,
2160 amount_in: BigUint::from(700u64),
2161 },
2162 SplitSwap {
2163 hop: HopDescriptor::new("component2".to_string(), token_a.clone(), token_b.clone()),
2164 split: 0.9,
2165 amount_in: BigUint::from(300u64),
2166 },
2167 ];
2168
2169 let result = splits_from_amounts(branch_collection, &BigUint::from(1000u64)).unwrap();
2170
2171 assert_eq!(result.len(), 2);
2172 assert_eq!(result[0].amount_in, BigUint::from(700u64));
2173 assert!((result[0].split - 0.7).abs() < 1e-9);
2174 assert_eq!(result[1].amount_in, BigUint::from(300u64));
2176 assert_eq!(result[1].split, 0.0);
2177 }
2178
2179 #[test]
2182 fn test_splits_from_amounts_ascending_amounts() {
2183 let token_a = token(0x0A, "A");
2184 let token_b = token(0x0B, "B");
2185
2186 let branch_collection = vec![
2187 SplitSwap {
2188 hop: HopDescriptor::new("small".to_string(), token_a.clone(), token_b.clone()),
2189 split: 0.0,
2190 amount_in: BigUint::from(300u64),
2191 },
2192 SplitSwap {
2193 hop: HopDescriptor::new("large".to_string(), token_a.clone(), token_b.clone()),
2194 split: 0.0,
2195 amount_in: BigUint::from(700u64),
2196 },
2197 ];
2198
2199 let result = splits_from_amounts(branch_collection, &BigUint::from(1000u64)).unwrap();
2200
2201 let order: Vec<&str> = result
2202 .iter()
2203 .map(|swap| swap.hop.component_id.as_str())
2204 .collect();
2205 assert_eq!(order, vec!["large", "small"], "the ascending input is sorted largest first");
2206 assert!((result[0].split - 0.7).abs() < 1e-9);
2207 assert_eq!(result[1].split, 0.0);
2210 assert_eq!(result[1].amount_in, BigUint::from(300u64));
2211 }
2212
2213 #[test]
2216 fn test_execute_split_plan_round_trip() {
2217 let token_a = token(0x0A, "A");
2218 let token_b = token(0x0B, "B");
2219 let market = make_market(vec![
2220 ("there", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(2.0))),
2221 ("back", vec![token_b.clone(), token_a.clone()], Box::new(MockProtocolSim::new(1.0))),
2222 ]);
2223
2224 let hops: Vec<HopDescriptor> = vec![
2225 HopDescriptor::new("there".to_string(), token_a.clone(), token_b.clone()),
2226 HopDescriptor::new("back".to_string(), token_b, token_a.clone()),
2227 ];
2228 let (total_out, _) = evaluate_total_output(
2229 &[&hops],
2230 &[1.0],
2231 &BigUint::from(1000u64),
2232 &market,
2233 &MarketOverrides::empty(),
2234 )
2235 .expect("the round trip simulates");
2236
2237 assert_eq!(total_out, BigUint::from(2000u64), "the order's own input is not output");
2239 }
2240
2241 #[test]
2246 fn test_token_balances_overspend() {
2247 let token_a = token(0x0A, "A");
2248 let mut balances = TokenBalances::starting(&token_a.address, &BigUint::from(100u64));
2249
2250 let error = balances
2251 .spend(&token_a, &"component1".to_string(), &BigUint::from(101u64))
2252 .expect_err("the swap spends more than stands at the token");
2253
2254 assert!(
2255 error
2256 .to_string()
2257 .contains("more than the 100 standing at it"),
2258 "the error states what the swap spends and what stands there: {error}"
2259 );
2260 }
2261
2262 #[test]
2265 fn test_amounts_for_branch_without_a_feeder() {
2266 let token_a = token(0x0A, "A");
2267 let token_b = token(0x0B, "B");
2268 let paths = vec![PathAllocation {
2269 hops: vec![SimulatedHop {
2270 descriptor: HopDescriptor::new(
2271 "component1".to_string(),
2272 token_a.clone(),
2273 token_b.clone(),
2274 ),
2275 amount_out: BigUint::from(2000u64),
2276 gas: BigUint::from(50_000u64),
2277 }],
2278 flow_fraction: 1.0,
2279 amount_in: BigUint::from(1000u64),
2280 amount_out: BigUint::from(2000u64),
2281 marginal_price_product: 2.0,
2282 }];
2283 let ledger = PathLedger::new(&paths).expect("the path carries an amount");
2284 let branch = vec![SplitSwap {
2286 hop: HopDescriptor::new("component2".to_string(), token_a, token_b),
2287 split: 0.0,
2288 amount_in: BigUint::ZERO,
2289 }];
2290
2291 let Err(error) =
2292 amounts_for_branch(branch, &FxHashMap::default(), &ledger, &BigUint::from(1000u64))
2293 else {
2294 panic!("a branch swap no path stands at must not be sized");
2295 };
2296
2297 assert!(
2298 error
2299 .to_string()
2300 .contains("no path feeds the swap through component2"),
2301 "the error names the swap that no path feeds: {error}"
2302 );
2303 }
2304
2305 #[test]
2306 fn test_execute_split_plan_zero_amount_paths() {
2307 let token_a = token(0x0A, "A");
2308 let token_b = token(0x0B, "B");
2309 let market = make_market(vec![(
2310 "component1",
2311 vec![token_a.clone(), token_b.clone()],
2312 Box::new(MockProtocolSim::new(2.0)),
2313 )]);
2314 let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
2315
2316 let paths = vec![PathAllocation {
2317 hops: vec![SimulatedHop {
2318 descriptor: HopDescriptor::new("component1".to_string(), token_a, token_b),
2319 amount_out: BigUint::ZERO,
2320 gas: BigUint::from(50_000u64),
2321 }],
2322 flow_fraction: 0.0,
2323 amount_in: BigUint::ZERO,
2324 amount_out: BigUint::ZERO,
2325 marginal_price_product: 0.0,
2326 }];
2327
2328 let error = build_split_route(&paths, &market, &ord)
2329 .expect_err("a path carrying nothing cannot be divided");
2330
2331 assert!(
2332 error
2333 .to_string()
2334 .contains("every path carries a zero amount"),
2335 "unexpected error: {error}"
2336 );
2337 }
2338
2339 #[test]
2340 fn test_splits_from_amounts_single_hop() {
2341 let token_a = token(0x0A, "A");
2342 let token_b = token(0x0B, "B");
2343
2344 let total = BigUint::from(1000u64);
2345 let branch_collection = vec![SplitSwap {
2346 hop: HopDescriptor::new("component1".to_string(), token_a, token_b),
2347 split: 1.0,
2348 amount_in: total.clone(),
2349 }];
2350
2351 let result = splits_from_amounts(branch_collection, &total).unwrap();
2352
2353 assert_eq!(result.len(), 1);
2354 assert_eq!(result[0].split, 0.0);
2355 assert_eq!(result[0].amount_in, total);
2356 }
2357
2358 #[test]
2359 fn test_share_output() {
2360 let shares =
2361 share_output(&BigUint::from(1000u64), &[BigUint::from(300u64), BigUint::from(100u64)]);
2362
2363 assert_eq!(shares, vec![BigUint::from(750u64), BigUint::from(250u64)]);
2364 }
2365
2366 #[test]
2368 fn test_share_output_uneven_division() {
2369 let output = BigUint::from(1000u64);
2370 let shares =
2371 share_output(&output, &[BigUint::from(1u64), BigUint::from(1u64), BigUint::from(1u64)]);
2372
2373 assert_eq!(shares.iter().sum::<BigUint>(), output);
2374 assert_eq!(shares[2], BigUint::from(334u64));
2375 }
2376
2377 #[test]
2380 fn test_build_split_route_remainder_convention() {
2381 let token_a = token(0x0A, "A");
2390 let token_b = token(0x0B, "B");
2391 let market = make_market(vec![
2392 (
2393 "component1",
2394 vec![token_a.clone(), token_b.clone()],
2395 Box::new(MockProtocolSim::new(2.0)),
2396 ),
2397 (
2398 "component2",
2399 vec![token_a.clone(), token_b.clone()],
2400 Box::new(MockProtocolSim::new(3.0)),
2401 ),
2402 (
2403 "component3",
2404 vec![token_a.clone(), token_b.clone()],
2405 Box::new(MockProtocolSim::new(4.0)),
2406 ),
2407 ]);
2408 let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
2409
2410 let gas = BigUint::from(50_000u64);
2411 let paths = vec![
2412 PathAllocation {
2413 hops: vec![SimulatedHop {
2414 descriptor: HopDescriptor::new(
2415 "component1".to_string(),
2416 token_a.clone(),
2417 token_b.clone(),
2418 ),
2419 amount_out: BigUint::from(1000u64),
2420 gas: gas.clone(),
2421 }],
2422 flow_fraction: 0.5,
2423 amount_in: BigUint::from(500u64),
2424 amount_out: BigUint::from(1000u64),
2425 marginal_price_product: 2.0,
2426 },
2427 PathAllocation {
2428 hops: vec![SimulatedHop {
2429 descriptor: HopDescriptor::new(
2430 "component2".to_string(),
2431 token_a.clone(),
2432 token_b.clone(),
2433 ),
2434 amount_out: BigUint::from(900u64),
2435 gas: gas.clone(),
2436 }],
2437 flow_fraction: 0.3,
2438 amount_in: BigUint::from(300u64),
2439 amount_out: BigUint::from(900u64),
2440 marginal_price_product: 3.0,
2441 },
2442 PathAllocation {
2443 hops: vec![SimulatedHop {
2444 descriptor: HopDescriptor::new(
2445 "component3".to_string(),
2446 token_a.clone(),
2447 token_b.clone(),
2448 ),
2449 amount_out: BigUint::from(800u64),
2450 gas,
2451 }],
2452 flow_fraction: 0.2,
2453 amount_in: BigUint::from(200u64),
2454 amount_out: BigUint::from(800u64),
2455 marginal_price_product: 4.0,
2456 },
2457 ];
2458
2459 let route = build_split_route(&paths, &market, &ord).unwrap();
2460 let swaps = route.swaps();
2461
2462 assert_eq!(swaps.len(), 3);
2463
2464 assert_eq!(swaps[0].component_id(), "component1");
2466 assert_eq!(*swaps[0].split(), 0.5);
2467 assert_eq!(swaps[1].component_id(), "component2");
2468 assert_eq!(*swaps[1].split(), 0.3);
2469 assert_eq!(swaps[2].component_id(), "component3");
2470 assert_eq!(*swaps[2].split(), 0.0);
2471 }
2472
2473 #[test]
2474 fn test_build_split_route_single_path() {
2475 let token_a = token(0x0A, "A");
2477 let token_b = token(0x0B, "B");
2478 let token_c = token(0x0C, "C");
2479 let market = make_market(vec![
2480 (
2481 "component_ab",
2482 vec![token_a.clone(), token_b.clone()],
2483 Box::new(MockProtocolSim::new(2.0)),
2484 ),
2485 (
2486 "component_bc",
2487 vec![token_b.clone(), token_c.clone()],
2488 Box::new(MockProtocolSim::new(3.0)),
2489 ),
2490 ]);
2491 let ord = order(&token_a, &token_c, 1000, OrderSide::Sell);
2492
2493 let gas = BigUint::from(50_000u64);
2494 let paths = vec![PathAllocation {
2495 hops: vec![
2496 SimulatedHop {
2497 descriptor: HopDescriptor::new(
2498 "component_ab".to_string(),
2499 token_a.clone(),
2500 token_b.clone(),
2501 ),
2502 amount_out: BigUint::from(2000u64),
2503 gas: gas.clone(),
2504 },
2505 SimulatedHop {
2506 descriptor: HopDescriptor::new("component_bc".to_string(), token_b, token_c),
2507 amount_out: BigUint::from(6000u64),
2508 gas,
2509 },
2510 ],
2511 flow_fraction: 1.0,
2512 amount_in: BigUint::from(1000u64),
2513 amount_out: BigUint::from(6000u64),
2514 marginal_price_product: 6.0,
2515 }];
2516
2517 let route = build_split_route(&paths, &market, &ord).unwrap();
2518 let swaps = route.swaps();
2519
2520 assert_eq!(swaps.len(), 2);
2521 for swap in swaps {
2522 assert_eq!(*swap.split(), 0.0, "single path should produce all-zero splits");
2523 }
2524 }
2525
2526 #[test]
2527 fn test_build_split_route_oversubscribed_paths() {
2528 let token_a = token(0x0A, "A");
2531 let token_b = token(0x0B, "B");
2532 let market = make_market(
2533 ["component_1", "component_2", "component_3"]
2534 .into_iter()
2535 .map(|component_id| {
2536 (
2537 component_id,
2538 vec![token_a.clone(), token_b.clone()],
2539 Box::new(MockProtocolSim::new(2.0)) as Box<dyn ProtocolSim>,
2540 )
2541 })
2542 .collect(),
2543 );
2544 let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
2545
2546 let paths: Vec<PathAllocation> = ["component_1", "component_2", "component_3"]
2547 .into_iter()
2548 .map(|component_id| PathAllocation {
2549 hops: vec![SimulatedHop {
2550 descriptor: HopDescriptor::new(
2551 component_id.to_string(),
2552 token_a.clone(),
2553 token_b.clone(),
2554 ),
2555 amount_out: BigUint::from(1200u64),
2556 gas: BigUint::from(50_000u64),
2557 }],
2558 flow_fraction: 0.6,
2559 amount_in: BigUint::from(600u64),
2560 amount_out: BigUint::from(1200u64),
2561 marginal_price_product: 2.0,
2562 })
2563 .collect();
2564
2565 let err = build_split_route(&paths, &market, &ord).unwrap_err();
2566
2567 assert!(
2568 err.to_string()
2569 .contains("the fractions ask for"),
2570 "an oversubscribed allocation must report the amounts, not panic: {err}"
2571 );
2572 }
2573
2574 #[test]
2575 fn test_build_split_route_shared_first_component() {
2576 let token_a = token(0x0A, "A");
2584 let token_b = token(0x0B, "B");
2585 let token_c = token(0x0C, "C");
2586 let market = make_market(vec![
2587 ("P1", vec![token_a.clone(), token_b.clone()], Box::new(MockProtocolSim::new(2.0))),
2588 ("P2", vec![token_b.clone(), token_c.clone()], Box::new(MockProtocolSim::new(3.0))),
2589 ("P3", vec![token_b.clone(), token_c.clone()], Box::new(MockProtocolSim::new(4.0))),
2590 ]);
2591 let ord = order(&token_a, &token_c, 1000, OrderSide::Sell);
2592
2593 let gas = BigUint::from(50_000u64);
2594 let paths = vec![
2595 PathAllocation {
2596 hops: vec![
2597 SimulatedHop {
2598 descriptor: HopDescriptor::new(
2599 "P1".to_string(),
2600 token_a.clone(),
2601 token_b.clone(),
2602 ),
2603 amount_out: BigUint::from(1400u64),
2604 gas: gas.clone(),
2605 },
2606 SimulatedHop {
2607 descriptor: HopDescriptor::new(
2608 "P2".to_string(),
2609 token_b.clone(),
2610 token_c.clone(),
2611 ),
2612 amount_out: BigUint::from(4200u64),
2613 gas: gas.clone(),
2614 },
2615 ],
2616 flow_fraction: 0.7,
2617 amount_in: BigUint::from(700u64),
2618 amount_out: BigUint::from(4200u64),
2619 marginal_price_product: 6.0,
2620 },
2621 PathAllocation {
2622 hops: vec![
2623 SimulatedHop {
2624 descriptor: HopDescriptor::new(
2625 "P1".to_string(),
2626 token_a.clone(),
2627 token_b.clone(),
2628 ),
2629 amount_out: BigUint::from(600u64),
2630 gas: gas.clone(),
2631 },
2632 SimulatedHop {
2633 descriptor: HopDescriptor::new(
2634 "P3".to_string(),
2635 token_b.clone(),
2636 token_c.clone(),
2637 ),
2638 amount_out: BigUint::from(2400u64),
2639 gas,
2640 },
2641 ],
2642 flow_fraction: 0.3,
2643 amount_in: BigUint::from(300u64),
2644 amount_out: BigUint::from(1200u64),
2645 marginal_price_product: 8.0,
2646 },
2647 ];
2648
2649 let route = build_split_route(&paths, &market, &ord).unwrap();
2650 let swaps = route.swaps();
2651
2652 assert_eq!(swaps.len(), 3, "expected 3 swaps, got {}", swaps.len());
2654
2655 let ab_swap = &swaps[0];
2657 assert_eq!(ab_swap.component_id(), "P1");
2658 assert_eq!(
2659 *ab_swap.amount_in(),
2660 BigUint::from(1000u64),
2661 "A→B swap amount_in should equal sum of both paths"
2662 );
2663 assert_eq!(
2664 *ab_swap.amount_out(),
2665 BigUint::from(2000u64),
2666 "A→B amount_out should be sum of per-path outputs (1400+600)"
2667 );
2668 assert_eq!(
2669 *ab_swap.split(),
2670 0.0,
2671 "A→B is the sole swap in its branch collection, so it gets the remainder convention (split = 0.0)"
2672 );
2673
2674 assert_eq!(swaps[1].component_id(), "P2");
2676 assert_eq!(*swaps[1].split(), 0.7);
2677 assert_eq!(swaps[2].component_id(), "P3");
2678 assert_eq!(*swaps[2].split(), 0.0);
2679 }
2680
2681 #[test]
2682 fn test_build_split_route_source_level_split_different_intermediates() {
2683 let token_a = token(0x0A, "A");
2692 let token_b = token(0x0B, "B");
2693 let token_c = token(0x0C, "C");
2694 let token_z = token(0x1A, "Z");
2695 let market = make_market(vec![
2696 (
2697 "component_ab",
2698 vec![token_a.clone(), token_b.clone()],
2699 Box::new(MockProtocolSim::new(2.0)),
2700 ),
2701 (
2702 "component_ac",
2703 vec![token_a.clone(), token_c.clone()],
2704 Box::new(MockProtocolSim::new(3.0)),
2705 ),
2706 (
2707 "component_bz",
2708 vec![token_b.clone(), token_z.clone()],
2709 Box::new(MockProtocolSim::new(4.0)),
2710 ),
2711 (
2712 "component_cz",
2713 vec![token_c.clone(), token_z.clone()],
2714 Box::new(MockProtocolSim::new(5.0)),
2715 ),
2716 ]);
2717 let ord = order(&token_a, &token_z, 1000, OrderSide::Sell);
2718
2719 let gas = BigUint::from(50_000u64);
2720 let paths = vec![
2721 PathAllocation {
2722 hops: vec![
2723 SimulatedHop {
2724 descriptor: HopDescriptor::new(
2725 "component_ab".to_string(),
2726 token_a.clone(),
2727 token_b.clone(),
2728 ),
2729 amount_out: BigUint::from(1200u64),
2730 gas: gas.clone(),
2731 },
2732 SimulatedHop {
2733 descriptor: HopDescriptor::new(
2734 "component_bz".to_string(),
2735 token_b,
2736 token_z.clone(),
2737 ),
2738 amount_out: BigUint::from(4800u64),
2739 gas: gas.clone(),
2740 },
2741 ],
2742 flow_fraction: 0.6,
2743 amount_in: BigUint::from(600u64),
2744 amount_out: BigUint::from(4800u64),
2745 marginal_price_product: 8.0,
2746 },
2747 PathAllocation {
2748 hops: vec![
2749 SimulatedHop {
2750 descriptor: HopDescriptor::new(
2751 "component_ac".to_string(),
2752 token_a.clone(),
2753 token_c.clone(),
2754 ),
2755 amount_out: BigUint::from(1200u64),
2756 gas: gas.clone(),
2757 },
2758 SimulatedHop {
2759 descriptor: HopDescriptor::new(
2760 "component_cz".to_string(),
2761 token_c,
2762 token_z,
2763 ),
2764 amount_out: BigUint::from(6000u64),
2765 gas,
2766 },
2767 ],
2768 flow_fraction: 0.4,
2769 amount_in: BigUint::from(400u64),
2770 amount_out: BigUint::from(6000u64),
2771 marginal_price_product: 15.0,
2772 },
2773 ];
2774
2775 let route = build_split_route(&paths, &market, &ord).unwrap();
2776 let swaps = route.swaps();
2777
2778 assert_eq!(swaps.len(), 4, "expected 4 swaps (2 source + 2 intermediate)");
2779
2780 assert_eq!(swaps[0].component_id(), "component_ab");
2782 assert_eq!(*swaps[0].split(), 0.6);
2783 assert_eq!(*swaps[0].amount_in(), BigUint::from(600u64));
2784 assert_eq!(*swaps[0].amount_out(), BigUint::from(1200u64));
2785
2786 assert_eq!(swaps[1].component_id(), "component_ac");
2787 assert_eq!(*swaps[1].split(), 0.0);
2788 assert_eq!(*swaps[1].amount_in(), BigUint::from(400u64));
2789 assert_eq!(*swaps[1].amount_out(), BigUint::from(1200u64));
2790
2791 assert_eq!(swaps[2].component_id(), "component_bz");
2793 assert_eq!(*swaps[2].split(), 0.0);
2794 assert_eq!(*swaps[2].amount_in(), BigUint::from(1200u64));
2795 assert_eq!(*swaps[2].amount_out(), BigUint::from(4800u64));
2796
2797 assert_eq!(swaps[3].component_id(), "component_cz");
2798 assert_eq!(*swaps[3].split(), 0.0);
2799 assert_eq!(*swaps[3].amount_in(), BigUint::from(1200u64));
2800 assert_eq!(*swaps[3].amount_out(), BigUint::from(6000u64));
2801 }
2802
2803 #[test]
2804 fn test_build_split_route_cross_depth_shared_component() {
2805 let weth = token(0x01, "WETH");
2819 let usdc = token(0x02, "USDC");
2820 let usdt = token(0x03, "USDT");
2821 let dai = token(0x04, "DAI");
2822 let market = make_market(vec![
2823 (
2824 "component_weth_usdc",
2825 vec![weth.clone(), usdc.clone()],
2826 Box::new(MockProtocolSim::new(2.0)),
2827 ),
2828 (
2829 "component_weth_usdt",
2830 vec![weth.clone(), usdt.clone()],
2831 Box::new(MockProtocolSim::new(3.0)),
2832 ),
2833 (
2834 "component_usdt_usdc",
2835 vec![usdt.clone(), usdc.clone()],
2836 Box::new(MockProtocolSim::new(1.0)),
2837 ),
2838 ("component_a", vec![usdc.clone(), dai.clone()], Box::new(MockProtocolSim::new(1.0))),
2839 ]);
2840 let ord = order(&weth, &dai, 1000, OrderSide::Sell);
2841
2842 let gas = BigUint::from(50_000u64);
2843
2844 let path1 = PathAllocation {
2847 hops: vec![
2848 HopDescriptor::new("component_weth_usdc".to_string(), weth.clone(), usdc.clone())
2849 .with_amounts(BigUint::from(1200u64), gas.clone()),
2850 HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2851 .with_amounts(BigUint::from(1200u64), gas.clone()),
2852 ],
2853 flow_fraction: 0.6,
2854 amount_in: BigUint::from(600u64),
2855 amount_out: BigUint::from(1200u64),
2856 marginal_price_product: 2.0,
2857 };
2858
2859 let path2 = PathAllocation {
2863 hops: vec![
2864 HopDescriptor::new("component_weth_usdt".to_string(), weth.clone(), usdt.clone())
2865 .with_amounts(BigUint::from(1200u64), gas.clone()),
2866 HopDescriptor::new("component_usdt_usdc".to_string(), usdt.clone(), usdc.clone())
2867 .with_amounts(BigUint::from(1200u64), gas.clone()),
2868 HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2869 .with_amounts(BigUint::from(1200u64), gas),
2870 ],
2871 flow_fraction: 0.4,
2872 amount_in: BigUint::from(400u64),
2873 amount_out: BigUint::from(1200u64),
2874 marginal_price_product: 3.0,
2875 };
2876
2877 let route = build_split_route(&[path1, path2], &market, &ord).unwrap();
2878 let swaps = route.swaps();
2879
2880 let component_a_swap = swaps
2883 .iter()
2884 .find(|s| s.component_id() == "component_a")
2885 .expect("component_a swap must exist");
2886 assert_eq!(
2887 *component_a_swap.amount_in(),
2888 BigUint::from(2400u64),
2889 "component_a must receive USDC from both paths (1200 + 1200)"
2890 );
2891 assert_eq!(
2892 *component_a_swap.amount_out(),
2893 BigUint::from(2400u64),
2894 "component_a amount_out should be the merged total"
2895 );
2896
2897 assert_eq!(swaps.len(), 4, "component_a must appear once, not once per path");
2900 assert_eq!(
2901 route.total_gas(),
2902 BigUint::from(200_000u64),
2903 "gas must be counted once per component, not once per path"
2904 );
2905 }
2906
2907 #[test]
2908 fn test_build_split_route_cross_depth_convergence_with_downstream_split() {
2909 let weth = token(0x01, "WETH");
2924 let usdc = token(0x02, "USDC");
2925 let usdt = token(0x03, "USDT");
2926 let dai = token(0x04, "DAI");
2927 let pepe = token(0x05, "PEPE");
2928 let market = make_market(vec![
2929 ("component_wu", vec![weth.clone(), usdc.clone()], Box::new(MockProtocolSim::new(2.0))),
2930 ("component_wt", vec![weth.clone(), usdt.clone()], Box::new(MockProtocolSim::new(3.0))),
2931 ("component_tu", vec![usdt.clone(), usdc.clone()], Box::new(MockProtocolSim::new(1.0))),
2932 ("component_a", vec![usdc.clone(), dai.clone()], Box::new(MockProtocolSim::new(1.0))),
2933 ("component_b", vec![dai.clone(), pepe.clone()], Box::new(MockProtocolSim::new(5.0))),
2934 ("component_c", vec![dai.clone(), pepe.clone()], Box::new(MockProtocolSim::new(4.0))),
2935 ]);
2936 let ord = order(&weth, &pepe, 1000, OrderSide::Sell);
2937 let gas = BigUint::from(50_000u64);
2938
2939 let path1 = PathAllocation {
2941 hops: vec![
2942 HopDescriptor::new("component_wu".to_string(), weth.clone(), usdc.clone())
2943 .with_amounts(BigUint::from(600u64), gas.clone()),
2944 HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2945 .with_amounts(BigUint::from(600u64), gas.clone()),
2946 HopDescriptor::new("component_b".to_string(), dai.clone(), pepe.clone())
2947 .with_amounts(BigUint::from(3000u64), gas.clone()),
2948 ],
2949 flow_fraction: 0.3,
2950 amount_in: BigUint::from(300u64),
2951 amount_out: BigUint::from(3000u64),
2952 marginal_price_product: 10.0,
2953 };
2954
2955 let path2 = PathAllocation {
2957 hops: vec![
2958 HopDescriptor::new("component_wu".to_string(), weth.clone(), usdc.clone())
2959 .with_amounts(BigUint::from(600u64), gas.clone()),
2960 HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2961 .with_amounts(BigUint::from(600u64), gas.clone()),
2962 HopDescriptor::new("component_c".to_string(), dai.clone(), pepe.clone())
2963 .with_amounts(BigUint::from(2400u64), gas.clone()),
2964 ],
2965 flow_fraction: 0.3,
2966 amount_in: BigUint::from(300u64),
2967 amount_out: BigUint::from(2400u64),
2968 marginal_price_product: 8.0,
2969 };
2970
2971 let path3 = PathAllocation {
2973 hops: vec![
2974 HopDescriptor::new("component_wt".to_string(), weth.clone(), usdt.clone())
2975 .with_amounts(BigUint::from(1200u64), gas.clone()),
2976 HopDescriptor::new("component_tu".to_string(), usdt.clone(), usdc.clone())
2977 .with_amounts(BigUint::from(1200u64), gas.clone()),
2978 HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
2979 .with_amounts(BigUint::from(1200u64), gas.clone()),
2980 HopDescriptor::new("component_b".to_string(), dai.clone(), pepe.clone())
2981 .with_amounts(BigUint::from(6000u64), gas),
2982 ],
2983 flow_fraction: 0.4,
2984 amount_in: BigUint::from(400u64),
2985 amount_out: BigUint::from(6000u64),
2986 marginal_price_product: 15.0,
2987 };
2988
2989 let route = build_split_route(&[path1, path2, path3], &market, &ord).unwrap();
2990 let swaps = route.swaps();
2991
2992 let component_a_swap = swaps
2995 .iter()
2996 .find(|s| s.component_id() == "component_a")
2997 .expect("component_a swap must exist");
2998 assert_eq!(
2999 *component_a_swap.amount_in(),
3000 BigUint::from(2400u64),
3001 "component_a must receive all USDC from both direct and USDT-detour paths"
3002 );
3003
3004 let component_b_swap = swaps
3010 .iter()
3011 .find(|s| s.component_id() == "component_b")
3012 .expect("component_b swap must exist");
3013 let component_c_swap = swaps
3014 .iter()
3015 .find(|s| s.component_id() == "component_c")
3016 .expect("component_c swap must exist");
3017
3018 assert_eq!(*component_b_swap.amount_in(), BigUint::from(1800u64));
3019 assert_eq!(
3020 *component_b_swap.amount_out(),
3021 BigUint::from(9000u64),
3022 "component_b amount_out should be simulated from emitted amount_in"
3023 );
3024 assert_eq!(*component_c_swap.amount_in(), BigUint::from(600u64));
3025 assert_eq!(
3026 *component_c_swap.amount_out(),
3027 BigUint::from(2400u64),
3028 "component_c amount_out should be simulated from remainder amount_in"
3029 );
3030
3031 let component_a_idx = swaps
3034 .iter()
3035 .position(|s| s.component_id() == "component_a")
3036 .unwrap();
3037 let component_b_idx = swaps
3038 .iter()
3039 .position(|s| s.component_id() == "component_b")
3040 .unwrap();
3041 let component_c_idx = swaps
3042 .iter()
3043 .position(|s| s.component_id() == "component_c")
3044 .unwrap();
3045 assert!(
3046 component_a_idx < component_b_idx && component_a_idx < component_c_idx,
3047 "component_a (idx {component_a_idx}) must appear before component_b (idx {component_b_idx}) \
3048 and component_c (idx {component_c_idx})"
3049 );
3050
3051 let component_tu_idx = swaps
3053 .iter()
3054 .position(|s| s.component_id() == "component_tu")
3055 .unwrap();
3056 assert!(
3057 component_tu_idx < component_a_idx,
3058 "component_tu (idx {component_tu_idx}) must appear before component_a (idx {component_a_idx})"
3059 );
3060
3061 assert_eq!(swaps.len(), 6, "component_a must appear once, not once per path");
3064 assert_eq!(
3065 route.total_gas(),
3066 BigUint::from(300_000u64),
3067 "gas must be counted once per component, not once per path"
3068 );
3069 }
3070
3071 #[test]
3072 fn test_build_split_route_rejects_reverse_order_shared_components() {
3073 let weth = token(0x01, "WETH");
3082 let usdc = token(0x02, "USDC");
3083 let dai = token(0x03, "DAI");
3084 let pepe = token(0x04, "PEPE");
3085 let uni = token(0x05, "UNI");
3086 let wbtc = token(0x06, "WBTC");
3087 let market = make_market(vec![
3088 ("component_wu", vec![weth.clone(), usdc.clone()], Box::new(MockProtocolSim::new(2.0))),
3089 ("component_a", vec![usdc.clone(), dai.clone()], Box::new(MockProtocolSim::new(1.0))),
3090 ("component_dp", vec![dai.clone(), pepe.clone()], Box::new(MockProtocolSim::new(5.0))),
3091 ("component_b", vec![pepe.clone(), uni.clone()], Box::new(MockProtocolSim::new(1.0))),
3092 ("component_uw", vec![uni.clone(), wbtc.clone()], Box::new(MockProtocolSim::new(3.0))),
3093 ("component_wp", vec![weth.clone(), pepe.clone()], Box::new(MockProtocolSim::new(4.0))),
3094 ("component_us", vec![uni.clone(), usdc.clone()], Box::new(MockProtocolSim::new(1.0))),
3095 ("component_dw", vec![dai.clone(), wbtc.clone()], Box::new(MockProtocolSim::new(2.0))),
3096 ]);
3097 let ord = order(&weth, &wbtc, 1000, OrderSide::Sell);
3098 let gas = BigUint::from(50_000u64);
3099
3100 let path1 = PathAllocation {
3101 hops: vec![
3102 HopDescriptor::new("component_wu".to_string(), weth.clone(), usdc.clone())
3103 .with_amounts(BigUint::from(1200u64), gas.clone()),
3104 HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
3105 .with_amounts(BigUint::from(1200u64), gas.clone()),
3106 HopDescriptor::new("component_dp".to_string(), dai.clone(), pepe.clone())
3107 .with_amounts(BigUint::from(6000u64), gas.clone()),
3108 HopDescriptor::new("component_b".to_string(), pepe.clone(), uni.clone())
3109 .with_amounts(BigUint::from(6000u64), gas.clone()),
3110 HopDescriptor::new("component_uw".to_string(), uni.clone(), wbtc.clone())
3111 .with_amounts(BigUint::from(18000u64), gas.clone()),
3112 ],
3113 flow_fraction: 0.6,
3114 amount_in: BigUint::from(600u64),
3115 amount_out: BigUint::from(18000u64),
3116 marginal_price_product: 30.0,
3117 };
3118
3119 let path2 = PathAllocation {
3120 hops: vec![
3121 HopDescriptor::new("component_wp".to_string(), weth.clone(), pepe.clone())
3122 .with_amounts(BigUint::from(1600u64), gas.clone()),
3123 HopDescriptor::new("component_b".to_string(), pepe.clone(), uni.clone())
3124 .with_amounts(BigUint::from(1600u64), gas.clone()),
3125 HopDescriptor::new("component_us".to_string(), uni.clone(), usdc.clone())
3126 .with_amounts(BigUint::from(1600u64), gas.clone()),
3127 HopDescriptor::new("component_a".to_string(), usdc.clone(), dai.clone())
3128 .with_amounts(BigUint::from(1600u64), gas.clone()),
3129 HopDescriptor::new("component_dw".to_string(), dai.clone(), wbtc.clone())
3130 .with_amounts(BigUint::from(3200u64), gas),
3131 ],
3132 flow_fraction: 0.4,
3133 amount_in: BigUint::from(400u64),
3134 amount_out: BigUint::from(3200u64),
3135 marginal_price_product: 8.0,
3136 };
3137
3138 let merged = merge_shared_hops(&[path1.clone(), path2.clone()]);
3140 assert_eq!(
3141 merged[&usdc.address]
3142 .iter()
3143 .filter(|s| s.hop.component_id == "component_a")
3144 .count(),
3145 1,
3146 "merge_shared_hops merges component_a into one"
3147 );
3148 assert_eq!(
3149 merged[&pepe.address]
3150 .iter()
3151 .filter(|s| s.hop.component_id == "component_b")
3152 .count(),
3153 1,
3154 "merge_shared_hops merges component_b into one"
3155 );
3156
3157 let err = build_split_route(&[path1, path2], &market, &ord)
3159 .expect_err("must reject cyclic path combination");
3160 assert!(
3161 matches!(&err, AlgorithmError::Other(msg) if msg.contains("dependency cycle")),
3162 "expected AlgorithmError::Other with dependency cycle, got: {err}"
3163 );
3164 }
3165}