Skip to content

Commit d5100f6

Browse files
dca intent limits improved
1 parent 7cdbc25 commit d5100f6

26 files changed

Lines changed: 1518 additions & 168 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ice/ice-solver/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "ice-solver"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55

66
[dependencies]

ice/ice-solver/src/v4/solver.rs

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ impl FeeCtx {
7272
/// Unordered pair key.
7373
type 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).
7690
type DirectionGroups<T> = (Vec<T>, Vec<T>);
7791

@@ -244,6 +258,18 @@ pub struct Solver<A: AMMInterface> {
244258

245259
impl<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,

ice/solver-bench/src/bin/ice_fuzz.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ fn main() {
6161

6262
// Replay one exact scenario from a worst-list seed (forces a single verbose
6363
// solver iteration so it works regardless of FUZZ_SECONDS/FUZZ_ITERS).
64-
let scenario_seed = std::env::var("FUZZ_SCENARIO_SEED").ok().and_then(|v| v.parse::<u64>().ok());
64+
let scenario_seed = std::env::var("FUZZ_SCENARIO_SEED")
65+
.ok()
66+
.and_then(|v| v.parse::<u64>().ok());
6567
let replay = scenario_seed.is_some();
6668

6769
let cfg = Config {

ice/solver-bench/src/fuzz/diff.rs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ impl DiffReport {
9999
println!("\n========== v3-vs-v4 DIFFERENTIAL ==========");
100100
if n == 0 {
101101
println!("no scenarios where both solvers produced a solution");
102-
println!("coverage: v3-only {} | v4-only {} | both-none {}", self.v3_only, self.v4_only, self.both_none);
102+
println!(
103+
"coverage: v3-only {} | v4-only {} | both-none {}",
104+
self.v3_only, self.v4_only, self.both_none
105+
);
103106
return;
104107
}
105108

@@ -137,7 +140,10 @@ impl DiffReport {
137140
let mean = sum_bps / n as i128;
138141

139142
println!("compared scenarios (both solved): {n}");
140-
println!("coverage: v3-only {} | v4-only {} | both-none {}", self.v3_only, self.v4_only, self.both_none);
143+
println!(
144+
"coverage: v3-only {} | v4-only {} | both-none {}",
145+
self.v3_only, self.v4_only, self.both_none
146+
);
141147
println!(
142148
"SCORE v4 wins {} ({:.1}%) | v4 loses {} ({:.1}%) | tie {} ({:.1}%)",
143149
wins,
@@ -185,8 +191,17 @@ impl DiffReport {
185191
}
186192

187193
/// Verbose side-by-side dump of one scenario (for replaying a worst-case seed).
188-
pub fn dump_scenario_detail(seed: u64, archetype: &str, intents: &[SolverIntent], v3: Option<&Solution>, v4: Option<&Solution>) {
189-
println!("\n----- scenario seed={seed} archetype={archetype} ({} intents) -----", intents.len());
194+
pub fn dump_scenario_detail(
195+
seed: u64,
196+
archetype: &str,
197+
intents: &[SolverIntent],
198+
v3: Option<&Solution>,
199+
v4: Option<&Solution>,
200+
) {
201+
println!(
202+
"\n----- scenario seed={seed} archetype={archetype} ({} intents) -----",
203+
intents.len()
204+
);
190205
for (i, it) in intents.iter().enumerate() {
191206
if let IntentData::Swap(s) = &it.data {
192207
println!(
@@ -199,16 +214,28 @@ pub fn dump_scenario_detail(seed: u64, archetype: &str, intents: &[SolverIntent]
199214
match sol {
200215
None => println!(" {label}: NO SOLUTION"),
201216
Some(s) => {
202-
println!(" {label}: score={} resolved={} trades={}", s.score, s.resolved_intents.len(), s.trades.len());
217+
println!(
218+
" {label}: score={} resolved={} trades={}",
219+
s.score,
220+
s.resolved_intents.len(),
221+
s.trades.len()
222+
);
203223
for r in s.resolved_intents.iter() {
204224
if let IntentData::Swap(sd) = &r.data {
205-
println!(" id={} {}->{} in={} out={}", r.id, sd.asset_in, sd.asset_out, sd.amount_in, sd.amount_out);
225+
println!(
226+
" id={} {}->{} in={} out={}",
227+
r.id, sd.asset_in, sd.asset_out, sd.amount_in, sd.amount_out
228+
);
206229
}
207230
}
208231
}
209232
}
210233
}
211234
if let (Some(a), Some(b)) = (v3, v4) {
212-
println!(" delta: v4-v3 score = {} ({} bps)", b.score as i128 - a.score as i128, rel_bps(a.score, b.score));
235+
println!(
236+
" delta: v4-v3 score = {} ({} bps)",
237+
b.score as i128 - a.score as i128,
238+
rel_bps(a.score, b.score)
239+
);
213240
}
214241
}

ice/solver-bench/src/fuzz/harness.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,11 +414,14 @@ fn tier2_scenario(
414414
// Solver panics are swallowed here (Tier 1 is the panic oracle) — a
415415
// panic just yields no solution and a clean rollback.
416416
let fee = cfg.fee;
417-
let call = pallet_ice::Pallet::<Runtime>::run(hydradx_runtime::System::block_number(), move |ints, st| {
418-
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| SolverV4::solve(ints, st, fee).ok()))
417+
let call =
418+
pallet_ice::Pallet::<Runtime>::run(hydradx_runtime::System::block_number(), move |ints, limits, st| {
419+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
420+
SolverV4::solve_with_limits(ints, limits.into_iter().collect(), st, fee).ok()
421+
}))
419422
.ok()
420423
.flatten()
421-
});
424+
});
422425

423426
let Some(pallet_ice::Call::submit_solution { solution, .. }) = call else {
424427
stats.no_solution += 1;

integration-tests/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "runtime-integration-tests"
3-
version = "1.105.0"
3+
version = "1.106.0"
44
description = "Integration tests"
55
authors = ["GalacticCouncil"]
66
edition = "2021"

0 commit comments

Comments
 (0)