@@ -72,6 +72,20 @@ impl FeeCtx {
7272/// Unordered pair key.
7373type AssetPair = ( AssetId , AssetId ) ;
7474
75+ /// Minimum output the chain enforces at resolution, for the intents where it is
76+ /// stricter than their own `amount_out`.
77+ ///
78+ /// Admission only. `amount_out` remains the sole basis for `surplus`, because the
79+ /// chain re-derives the score from storage and any divergence is a `ScoreMismatch`.
80+ /// These floors are recomputed from an oracle and must never reach the score.
81+ type MinOuts = BTreeMap < IntentId , Balance > ;
82+
83+ /// Numerator of the rate an intent must clear to be admitted: its floor when one
84+ /// is supplied, otherwise its own `amount_out`.
85+ fn admission_n ( id : IntentId , swap : & SwapData , min_outs : & MinOuts ) -> Balance {
86+ min_outs. get ( & id) . copied ( ) . unwrap_or ( swap. amount_out )
87+ }
88+
7589/// Intents grouped by direction: (forward A→B, backward B→A).
7690type DirectionGroups < T > = ( Vec < T > , Vec < T > ) ;
7791
@@ -244,6 +258,18 @@ pub struct Solver<A: AMMInterface> {
244258
245259impl < A : AMMInterface > Solver < A > {
246260 pub fn solve ( intents : Vec < Intent > , initial_state : A :: State , matched_fee : Permill ) -> Result < Solution , A :: Error > {
261+ Self :: solve_with_limits ( intents, MinOuts :: new ( ) , initial_state, matched_fee)
262+ }
263+
264+ /// As `solve`, with per-intent admission floors (see `MinOuts`). An intent
265+ /// that cannot be paid its floor at the batch's uniform rate is excluded,
266+ /// exactly as one whose own `amount_out` cannot be met.
267+ pub fn solve_with_limits (
268+ intents : Vec < Intent > ,
269+ min_outs : MinOuts ,
270+ initial_state : A :: State ,
271+ matched_fee : Permill ,
272+ ) -> Result < Solution , A :: Error > {
247273 if intents. is_empty ( ) {
248274 return Ok ( empty_solution ( ) ) ;
249275 }
@@ -266,7 +292,7 @@ impl<A: AMMInterface> Solver<A> {
266292 return Ok ( empty_solution ( ) ) ;
267293 }
268294 if candidates. len ( ) == 1 {
269- return Self :: solve_single_intent ( candidates[ 0 ] , & initial_state, & mut cache) ;
295+ return Self :: solve_single_intent ( candidates[ 0 ] , & min_outs , & initial_state, & mut cache) ;
270296 }
271297
272298 // Group candidates per unordered pair, split by direction.
@@ -280,7 +306,9 @@ impl<A: AMMInterface> Solver<A> {
280306 intent,
281307 remaining,
282308 fill : remaining,
283- limit_n : swap. amount_out ,
309+ // The crossing engine must sort and trim on the limit the chain
310+ // enforces, not the one stored on the intent.
311+ limit_n : admission_n ( intent. id , swap, & min_outs) ,
284312 limit_d : swap. amount_in ,
285313 partial : swap. partial . is_partial ( ) ,
286314 } ;
@@ -334,7 +362,7 @@ impl<A: AMMInterface> Solver<A> {
334362 if included. len ( ) == 1 {
335363 let intent = included[ 0 ] ;
336364 let fill = fills. get ( & intent. id ) . copied ( ) . unwrap_or ( 0 ) ;
337- return Self :: solve_single_intent_with_fill ( intent, fill, & initial_state, & mut cache) ;
365+ return Self :: solve_single_intent_with_fill ( intent, fill, & min_outs , & initial_state, & mut cache) ;
338366 }
339367
340368 // Stabilization rounds: rings → trades → unified rates → resolution.
@@ -344,8 +372,15 @@ impl<A: AMMInterface> Solver<A> {
344372 for round in 0 ..MAX_STABILIZATION_ROUNDS {
345373 log:: debug!( target: LOG_TARGET , "stabilization round {}, {} included intents" , round, included. len( ) ) ;
346374
347- let ( resolved_intents, executed_trades, total_score) =
348- Self :: netting_round ( & included, & fills, & spot_prices, & initial_state, & mut cache, fee_ctx) ;
375+ let ( resolved_intents, executed_trades, total_score) = Self :: netting_round (
376+ & included,
377+ & fills,
378+ & min_outs,
379+ & spot_prices,
380+ & initial_state,
381+ & mut cache,
382+ fee_ctx,
383+ ) ;
349384
350385 log:: debug!( target: LOG_TARGET , "round {}: {} resolved, {} trades, score: {} (from {} included)" ,
351386 round, resolved_intents. len( ) , executed_trades. len( ) , total_score, included. len( ) ) ;
@@ -367,7 +402,7 @@ impl<A: AMMInterface> Solver<A> {
367402 if included. len ( ) == 1 {
368403 let intent = included[ 0 ] ;
369404 let fill = fills. get ( & intent. id ) . copied ( ) . unwrap_or ( 0 ) ;
370- return Self :: solve_single_intent_with_fill ( intent, fill, & initial_state, & mut cache) ;
405+ return Self :: solve_single_intent_with_fill ( intent, fill, & min_outs , & initial_state, & mut cache) ;
371406 }
372407 }
373408
@@ -382,7 +417,7 @@ impl<A: AMMInterface> Solver<A> {
382417 continue ;
383418 } ;
384419 let fill = fills. get ( & intent. id ) . copied ( ) . unwrap_or_else ( || swap. remaining ( ) ) ;
385- let solution = Self :: solve_single_intent_with_fill ( intent, fill, & initial_state, & mut cache) ?;
420+ let solution = Self :: solve_single_intent_with_fill ( intent, fill, & min_outs , & initial_state, & mut cache) ?;
386421 if !solution. resolved_intents . is_empty ( ) {
387422 return Ok ( solution) ;
388423 }
@@ -808,6 +843,7 @@ impl<A: AMMInterface> Solver<A> {
808843 fn netting_round (
809844 included : & [ & Intent ] ,
810845 fills : & BTreeMap < IntentId , Balance > ,
846+ min_outs : & MinOuts ,
811847 spot_prices : & BTreeMap < AssetId , Ratio > ,
812848 initial_state : & A :: State ,
813849 cache : & mut QuoteCache < A > ,
@@ -844,7 +880,7 @@ impl<A: AMMInterface> Solver<A> {
844880 }
845881 let ( Some ( v) , true ) = ( to_hdx ( fill, swap. asset_in ) , spot_prices. contains_key ( & swap. asset_out ) ) else {
846882 // Unpriced asset — fall back to the v3 per-pair engine for the batch.
847- return Self :: run_round ( included, fills, spot_prices, initial_state, cache, fee_ctx) ;
883+ return Self :: run_round ( included, fills, min_outs , spot_prices, initial_state, cache, fee_ctx) ;
848884 } ;
849885 let si = sold_native. entry ( swap. asset_in ) . or_insert ( 0 ) ;
850886 * si = si. saturating_add ( fill) ;
@@ -1015,8 +1051,15 @@ impl<A: AMMInterface> Solver<A> {
10151051 continue ;
10161052 }
10171053
1054+ // Admission on the enforced floor, score on the stored `amount_out`
1055+ // — the chain re-derives the score from storage.
1056+ let admission = apply_rate (
1057+ fill,
1058+ U256 :: from ( admission_n ( intent. id , swap, min_outs) ) ,
1059+ U256 :: from ( swap. amount_in ) ,
1060+ ) ;
10181061 let min_required = apply_rate ( fill, U256 :: from ( swap. amount_out ) , U256 :: from ( swap. amount_in ) ) ;
1019- if total_out < min_required {
1062+ if total_out < admission || total_out < min_required {
10201063 continue ;
10211064 }
10221065
@@ -1046,6 +1089,7 @@ impl<A: AMMInterface> Solver<A> {
10461089 fn run_round (
10471090 included : & [ & Intent ] ,
10481091 fills : & BTreeMap < IntentId , Balance > ,
1092+ min_outs : & MinOuts ,
10491093 spot_prices : & BTreeMap < AssetId , Ratio > ,
10501094 initial_state : & A :: State ,
10511095 cache : & mut QuoteCache < A > ,
@@ -1320,10 +1364,15 @@ impl<A: AMMInterface> Solver<A> {
13201364 continue ;
13211365 }
13221366
1367+ let admission = apply_rate (
1368+ fill,
1369+ U256 :: from ( admission_n ( intent. id , swap, min_outs) ) ,
1370+ U256 :: from ( swap. amount_in ) ,
1371+ ) ;
13231372 let min_required = apply_rate ( fill, U256 :: from ( swap. amount_out ) , U256 :: from ( swap. amount_in ) ) ;
1324- if total_out < min_required {
1325- log:: debug!( target: LOG_TARGET , "intent {}: skipped — output {} < pro_rata_min {} for fill {}" ,
1326- intent. id, total_out, min_required, fill) ;
1373+ if total_out < admission || total_out < min_required {
1374+ log:: debug!( target: LOG_TARGET , "intent {}: skipped — output {} < pro_rata_min {} (admission {}) for fill {}" ,
1375+ intent. id, total_out, min_required, admission , fill) ;
13271376 continue ;
13281377 }
13291378
@@ -1349,13 +1398,14 @@ impl<A: AMMInterface> Solver<A> {
13491398 /// Single intent path, supporting partial fills.
13501399 fn solve_single_intent (
13511400 intent : & Intent ,
1401+ min_outs : & MinOuts ,
13521402 initial_state : & A :: State ,
13531403 cache : & mut QuoteCache < A > ,
13541404 ) -> Result < Solution , A :: Error > {
13551405 let IntentData :: Swap ( swap) = & intent. data else {
13561406 return Ok ( empty_solution ( ) ) ;
13571407 } ;
1358- Self :: solve_single_intent_with_fill ( intent, swap. remaining ( ) , initial_state, cache)
1408+ Self :: solve_single_intent_with_fill ( intent, swap. remaining ( ) , min_outs , initial_state, cache)
13591409 }
13601410
13611411 /// Single intent with a specific fill amount.
@@ -1367,6 +1417,7 @@ impl<A: AMMInterface> Solver<A> {
13671417 fn solve_single_intent_with_fill (
13681418 intent : & Intent ,
13691419 fill : Balance ,
1420+ min_outs : & MinOuts ,
13701421 initial_state : & A :: State ,
13711422 cache : & mut QuoteCache < A > ,
13721423 ) -> Result < Solution , A :: Error > {
@@ -1380,7 +1431,9 @@ impl<A: AMMInterface> Solver<A> {
13801431 log:: debug!( target: LOG_TARGET , "solving single intent {}: {} -> {}, fill: {}, min_rate: {}/{}" ,
13811432 intent. id, swap. asset_in, swap. asset_out, fill, swap. amount_out, swap. amount_in) ;
13821433
1383- let min_n = U256 :: from ( swap. amount_out ) ;
1434+ // Admission clears the enforced floor; the score below stays on `amount_out`.
1435+ let min_n = U256 :: from ( admission_n ( intent. id , swap, min_outs) ) ;
1436+ let score_n = U256 :: from ( swap. amount_out ) ;
13841437 let min_d = U256 :: from ( swap. amount_in ) ;
13851438 let ed_in = A :: existential_deposit ( swap. asset_in ) ;
13861439 let ed_out = A :: existential_deposit ( swap. asset_out ) ;
@@ -1442,8 +1495,7 @@ impl<A: AMMInterface> Solver<A> {
14421495 return Ok ( empty_solution ( ) ) ;
14431496 }
14441497
1445- let pro_rata_min = apply_rate ( actual_fill, min_n, min_d) ;
1446- let surplus = net_out. saturating_sub ( pro_rata_min) ;
1498+ let surplus = net_out. saturating_sub ( apply_rate ( actual_fill, score_n, min_d) ) ;
14471499
14481500 let resolved = ResolvedIntent {
14491501 id : intent. id ,
0 commit comments