Skip to content

Commit aadea01

Browse files
aeyakovenkoclaude
andcommitted
fix: v11.26 spec compliance — flat-close guard, fee-neutral risk-reducing, constants
v11.26 change aeyakovenko#2 — Flat-close guard uses Eq_maint_raw_i >= 0: enforce_one_side_margin now checks account_equity_maint_raw_wide >= 0 for flat exits, not just pnl >= 0. Prevents flat exit with negative net wealth from fee debt (C + PNL - FeeDebt < 0). TDD: proof_v1126_flat_close_uses_eq_maint_raw (19s, PASS) v11.26 change #1 — Fee-neutral risk-reducing exemption: Buffer comparison now adds fee back: (Eq_maint_raw_post + fee) - MM_req_post. Also enforces shortfall guard: min(Eq_maint_raw_post + fee, 0) >= min(pre, 0). Pure fee friction no longer blocks genuine de-risking trades. TDD: proof_v1126_risk_reducing_fee_neutral (18s, 1/1 cover) Other fixes: - MAX_TRADE_SIZE_Q: 200T → MAX_POSITION_ABS_Q (100T) per spec §1.4 - liquidate_at_oracle OI assertion now unconditional per spec §10.6 step 10 - enforce_one_side_margin takes fee parameter for fee-neutral comparison Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9feedae commit aadea01

2 files changed

Lines changed: 167 additions & 30 deletions

File tree

src/percolator.rs

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ pub const MAX_ABS_FUNDING_BPS_PER_SLOT: i64 = 10_000;
5858
pub const MAX_VAULT_TVL: u128 = 10_000_000_000_000_000;
5959
pub const MAX_POSITION_ABS_Q: u128 = 100_000_000_000_000;
6060
pub const MAX_ACCOUNT_NOTIONAL: u128 = 100_000_000_000_000_000_000;
61-
pub const MAX_TRADE_SIZE_Q: u128 = 200_000_000_000_000;
61+
pub const MAX_TRADE_SIZE_Q: u128 = MAX_POSITION_ABS_Q; // spec §1.4
6262
pub const MAX_OI_SIDE_Q: u128 = 100_000_000_000_000;
6363
pub const MAX_MATERIALIZED_ACCOUNTS: u64 = 1_000_000;
6464
pub const MAX_ACCOUNT_POSITIVE_PNL: u128 = 100_000_000_000_000_000_000_000_000_000_000;
@@ -2402,7 +2402,7 @@ impl RiskEngine {
24022402
self.enforce_post_trade_margin(
24032403
a as usize, b as usize, oracle_price,
24042404
&old_eff_a, &new_eff_a, &old_eff_b, &new_eff_b,
2405-
buffer_pre_a, buffer_pre_b,
2405+
buffer_pre_a, buffer_pre_b, fee,
24062406
)?;
24072407

24082408
// Steps 16-17: end-of-instruction resets
@@ -2486,9 +2486,10 @@ impl RiskEngine {
24862486
new_eff_b: &i128,
24872487
buffer_pre_a: I256,
24882488
buffer_pre_b: I256,
2489+
fee: u128,
24892490
) -> Result<()> {
2490-
self.enforce_one_side_margin(a, oracle_price, old_eff_a, new_eff_a, buffer_pre_a)?;
2491-
self.enforce_one_side_margin(b, oracle_price, old_eff_b, new_eff_b, buffer_pre_b)?;
2491+
self.enforce_one_side_margin(a, oracle_price, old_eff_a, new_eff_a, buffer_pre_a, fee)?;
2492+
self.enforce_one_side_margin(b, oracle_price, old_eff_b, new_eff_b, buffer_pre_b, fee)?;
24922493
Ok(())
24932494
}
24942495

@@ -2499,10 +2500,13 @@ impl RiskEngine {
24992500
old_eff: &i128,
25002501
new_eff: &i128,
25012502
buffer_pre: I256,
2503+
fee: u128,
25022504
) -> Result<()> {
25032505
if *new_eff == 0 {
2504-
// Flat: PnL must be >= 0 after settle_losses (steps 25-26)
2505-
if self.accounts[idx].pnl < 0 {
2506+
// v11.26 §10.5 step 29: flat-close guard uses exact Eq_maint_raw_i >= 0
2507+
// (not just PNL >= 0). Prevents flat exits with negative net wealth from fee debt.
2508+
let maint_raw = self.account_equity_maint_raw_wide(&self.accounts[idx]);
2509+
if maint_raw.is_negative() {
25062510
return Err(RiskError::Undercollateralized);
25072511
}
25082512
return Ok(());
@@ -2531,32 +2535,42 @@ impl RiskEngine {
25312535
} else if self.is_above_maintenance_margin(&self.accounts[idx], idx, oracle_price) {
25322536
// Maintenance healthy: allow
25332537
} else if strictly_reducing {
2534-
// Strict risk-reducing: allow only if post-trade raw maintenance buffer
2535-
// is strictly greater than pre-trade buffer (spec §10.5 step 29)
2536-
// Uses exact I256 per §3.4 — no saturation or clamping.
2537-
//
2538-
// Additionally, raw equity must not decrease (accounting for fees):
2539-
// Eq_maint_raw_post + fee >= Eq_maint_raw_pre
2540-
// This prevents execution slippage from being weaponized to siphon
2541-
// money from a bankrupt account (buffer masking via MM_req drop).
2538+
// v11.26 §10.5 step 29: strict risk-reducing exemption (fee-neutral).
2539+
// Both conditions must hold in exact widened I256:
2540+
// 1. Fee-neutral buffer improves: (Eq_maint_raw_post + fee) - MM_req_post > buffer_pre
2541+
// 2. Fee-neutral shortfall does not worsen: min(Eq_maint_raw_post + fee, 0) >= min(Eq_maint_raw_pre, 0)
25422542
let maint_raw_wide_post = self.account_equity_maint_raw_wide(&self.accounts[idx]);
2543-
let maint_raw_wide_pre = buffer_pre.checked_add(I256::from_u128({
2543+
let fee_wide = I256::from_u128(fee);
2544+
2545+
// Fee-neutral post equity and buffer
2546+
let maint_raw_fee_neutral = maint_raw_wide_post.checked_add(fee_wide).expect("I256 add");
2547+
let mm_req_post = {
2548+
let not = self.notional(idx, oracle_price);
2549+
mul_u128(not, self.params.maintenance_margin_bps as u128) / 10_000
2550+
};
2551+
let buffer_post_fee_neutral = maint_raw_fee_neutral.checked_sub(I256::from_u128(mm_req_post)).expect("I256 sub");
2552+
2553+
// Recover pre-trade raw equity from buffer_pre + MM_req_pre
2554+
let mm_req_pre = {
25442555
let not_pre = if *old_eff == 0 { 0u128 } else {
25452556
mul_div_floor_u128(old_eff.unsigned_abs(), oracle_price as u128, POS_SCALE)
25462557
};
25472558
mul_u128(not_pre, self.params.maintenance_margin_bps as u128) / 10_000
2548-
})).expect("I256 add");
2549-
// Guard: raw equity must not decrease (prevents slippage extraction)
2550-
if maint_raw_wide_post < maint_raw_wide_pre {
2551-
return Err(RiskError::Undercollateralized);
2552-
}
2553-
let mm_req_post = {
2554-
let not = self.notional(idx, oracle_price);
2555-
mul_u128(not, self.params.maintenance_margin_bps as u128) / 10_000
25562559
};
2557-
let buffer_post = maint_raw_wide_post.checked_sub(I256::from_u128(mm_req_post)).expect("I256 sub");
2558-
if buffer_post > buffer_pre {
2559-
// Improved: allow
2560+
let maint_raw_pre = buffer_pre.checked_add(I256::from_u128(mm_req_pre)).expect("I256 add");
2561+
2562+
// Condition 1: fee-neutral buffer strictly improves
2563+
let cond1 = buffer_post_fee_neutral > buffer_pre;
2564+
2565+
// Condition 2: fee-neutral shortfall below zero does not worsen
2566+
// min(post + fee, 0) >= min(pre, 0)
2567+
let zero = I256::from_i128(0);
2568+
let shortfall_post = if maint_raw_fee_neutral < zero { maint_raw_fee_neutral } else { zero };
2569+
let shortfall_pre = if maint_raw_pre < zero { maint_raw_pre } else { zero };
2570+
let cond2 = shortfall_post >= shortfall_pre;
2571+
2572+
if cond1 && cond2 {
2573+
// Both conditions met: allow
25602574
} else {
25612575
return Err(RiskError::Undercollateralized);
25622576
}
@@ -2630,10 +2644,9 @@ impl RiskEngine {
26302644
self.finalize_end_of_instruction_resets(&ctx);
26312645
self.recompute_r_last_from_final_state();
26322646

2633-
if result {
2634-
// Assert OI balance (spec §10.5)
2635-
assert!(self.oi_eff_long_q == self.oi_eff_short_q, "OI_eff_long != OI_eff_short after liquidation");
2636-
}
2647+
// Assert OI balance unconditionally (spec §10.6 step 10)
2648+
// touch_account_full mutates side state even when liquidation doesn't proceed.
2649+
assert!(self.oi_eff_long_q == self.oi_eff_short_q, "OI_eff_long != OI_eff_short after liquidation");
26372650
Ok(result)
26382651
}
26392652

tests/proofs_safety.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,3 +1170,127 @@ fn proof_settle_fee_rejects_i128_min() {
11701170
assert!(result.is_err(),
11711171
"engine must reject fee decrement that would produce i128::MIN");
11721172
}
1173+
1174+
// ############################################################################
1175+
// v11.26 compliance: flat-close guard uses Eq_maint_raw_i >= 0
1176+
// ############################################################################
1177+
1178+
/// v11.26 change #2: A trade that closes to flat must use Eq_maint_raw_i >= 0,
1179+
/// not just PNL_i >= 0. An account with positive PNL but large fee debt
1180+
/// (Eq_maint_raw_i = C + PNL - FeeDebt < 0) must be rejected.
1181+
#[kani::proof]
1182+
#[kani::unwind(70)]
1183+
#[kani::solver(cadical)]
1184+
fn proof_v1126_flat_close_uses_eq_maint_raw() {
1185+
let mut params = zero_fee_params();
1186+
params.trading_fee_bps = 100; // 1% fee
1187+
let mut engine = RiskEngine::new(params);
1188+
engine.last_crank_slot = DEFAULT_SLOT;
1189+
1190+
let a = engine.add_user(0).unwrap();
1191+
let b = engine.add_user(0).unwrap();
1192+
engine.deposit(a, 100_000, DEFAULT_ORACLE, DEFAULT_SLOT).unwrap();
1193+
engine.deposit(b, 500_000, DEFAULT_ORACLE, DEFAULT_SLOT).unwrap();
1194+
1195+
// Open position for a
1196+
let size = (500 * POS_SCALE) as i128;
1197+
engine.execute_trade(a, b, DEFAULT_ORACLE, DEFAULT_SLOT, size, DEFAULT_ORACLE).unwrap();
1198+
1199+
// Drain a's capital to 0, give positive PNL but massive fee debt
1200+
engine.set_capital(a as usize, 0);
1201+
engine.set_pnl(a as usize, 1000i128); // positive PNL
1202+
engine.accounts[a as usize].fee_credits = I128::new(-5000); // fee debt
1203+
1204+
// Eq_maint_raw = C(0) + PNL(1000) - FeeDebt(5000) = -4000 < 0
1205+
// v11.26 requires: reject flat close when Eq_maint_raw < 0
1206+
// Old code only checks PNL >= 0 which would pass (PNL = 1000 > 0)
1207+
1208+
let close_size = -size;
1209+
let result = engine.execute_trade(a, b, DEFAULT_ORACLE, DEFAULT_SLOT, close_size, DEFAULT_ORACLE);
1210+
1211+
// Must be rejected: Eq_maint_raw < 0 even though PNL > 0
1212+
assert!(result.is_err(),
1213+
"v11.26: flat close must be rejected when Eq_maint_raw < 0 (fee debt exceeds C + PNL)");
1214+
}
1215+
1216+
// ############################################################################
1217+
// v11.26 compliance: risk-reducing exemption is fee-neutral
1218+
// ############################################################################
1219+
1220+
/// v11.26 change #1: The risk-reducing buffer comparison must be fee-neutral.
1221+
/// A genuine de-risking trade must not fail solely because the trading fee
1222+
/// reduces post-trade equity.
1223+
#[kani::proof]
1224+
#[kani::unwind(70)]
1225+
#[kani::solver(cadical)]
1226+
fn proof_v1126_risk_reducing_fee_neutral() {
1227+
let mut params = zero_fee_params();
1228+
params.trading_fee_bps = 100; // 1% fee to make fee friction visible
1229+
let mut engine = RiskEngine::new(params);
1230+
engine.last_crank_slot = DEFAULT_SLOT;
1231+
1232+
let a = engine.add_user(0).unwrap();
1233+
let b = engine.add_user(0).unwrap();
1234+
engine.deposit(a, 100_000, DEFAULT_ORACLE, DEFAULT_SLOT).unwrap();
1235+
engine.deposit(b, 500_000, DEFAULT_ORACLE, DEFAULT_SLOT).unwrap();
1236+
1237+
// Open leveraged position
1238+
let size = (800 * POS_SCALE) as i128;
1239+
engine.execute_trade(a, b, DEFAULT_ORACLE, DEFAULT_SLOT, size, DEFAULT_ORACLE).unwrap();
1240+
1241+
// Push below maintenance
1242+
engine.set_pnl(a as usize, -50_000i128);
1243+
1244+
// Risk-reducing: close half at oracle price (no slippage)
1245+
let half_close = -(size / 2);
1246+
let result = engine.execute_trade(a, b, DEFAULT_ORACLE, DEFAULT_SLOT, half_close, DEFAULT_ORACLE);
1247+
1248+
// v11.26: fee-neutral comparison means pure fee friction should not block
1249+
// a genuine de-risking trade at oracle price.
1250+
// The post-trade buffer (with fee added back) should be strictly better.
1251+
// Conservation must hold regardless of whether trade succeeds or fails.
1252+
assert!(engine.check_conservation());
1253+
kani::cover!(result.is_ok(), "fee-neutral risk-reducing trade accepted");
1254+
}
1255+
1256+
// ############################################################################
1257+
// v11.26 compliance: MIN_NONZERO_MM_REQ floor (TODO: implement params first)
1258+
// ############################################################################
1259+
1260+
// Commented out until RiskParams gains min_nonzero_mm_req / min_nonzero_im_req
1261+
/*
1262+
#[kani::proof]
1263+
#[kani::unwind(70)]
1264+
#[kani::solver(cadical)]
1265+
fn proof_v1126_min_nonzero_margin_floor() {
1266+
let mut params = zero_fee_params();
1267+
params.min_nonzero_mm_req = 1000; // $0.001 floor
1268+
params.min_nonzero_im_req = 2000; // $0.002 floor
1269+
let mut engine = RiskEngine::new(params);
1270+
engine.last_crank_slot = DEFAULT_SLOT;
1271+
1272+
let a = engine.add_user(0).unwrap();
1273+
let b = engine.add_user(0).unwrap();
1274+
engine.deposit(a, 100_000, DEFAULT_ORACLE, DEFAULT_SLOT).unwrap();
1275+
engine.deposit(b, 500_000, DEFAULT_ORACLE, DEFAULT_SLOT).unwrap();
1276+
1277+
// Tiny position: notional so small that proportional MM floors to 0
1278+
let tiny_size = 1i128; // 1 unit of position
1279+
let result = engine.execute_trade(a, b, DEFAULT_ORACLE, DEFAULT_SLOT, tiny_size, DEFAULT_ORACLE);
1280+
1281+
if result.is_ok() {
1282+
// The position exists with nonzero effective pos
1283+
let eff = engine.effective_pos_q(a as usize);
1284+
if eff != 0 {
1285+
// MM_req must be at least MIN_NONZERO_MM_REQ
1286+
let notional = engine.notional(a as usize, DEFAULT_ORACLE);
1287+
let proportional_mm = mul_u128(notional, params.maintenance_margin_bps as u128) / 10_000;
1288+
// If proportional floors to 0, the min floor must apply
1289+
if proportional_mm == 0 {
1290+
kani::cover!(true, "proportional MM floors to 0 — min floor must apply");
1291+
}
1292+
}
1293+
}
1294+
assert!(engine.check_conservation());
1295+
}
1296+
*/

0 commit comments

Comments
 (0)