@@ -15,11 +15,11 @@ use crate::tests::claim_root::{
1515} ;
1616use crate :: tests:: mock:: * ;
1717use crate :: {
18- BASKET_TRADE_WINDOW_BLOCKS , BasketClaimed , BasketDailyTurnoverCap , BasketRate ,
18+ BASKET_TRADE_WINDOW_BLOCKS , BasketClaimed , BasketDailyTurnoverCap , BasketRate , BasketShares ,
1919 BasketTradeWindow , BasketTradingEnabled , BasketTradingFrozen , ColdkeySwapAnnouncements ,
20- DEFAULT_BASKET_DAILY_TURNOVER_CAP , DefaultMinStake , Error , Event , RootWeightsCap ,
21- SubnetAlphaIn , SubnetAlphaOut , SubnetMovingPrice , SubnetProtocolFlow , SubnetTAO , SubnetTaoFlow ,
22- SubtokenEnabled , TotalStake , Uids ,
20+ DEFAULT_BASKET_DAILY_TURNOVER_CAP , DefaultMinStake , Error , Event , NetworksAdded ,
21+ RootWeightsCap , SubnetAlphaIn , SubnetAlphaOut , SubnetMovingPrice , SubnetProtocolFlow ,
22+ SubnetTAO , SubnetTaoFlow , SubtokenEnabled , TotalStake , Uids ,
2323} ;
2424use codec:: Encode ;
2525use frame_support:: dispatch:: DispatchResultWithPostInfo ;
@@ -30,6 +30,7 @@ use sp_core::U256;
3030use sp_runtime:: traits:: Hash ;
3131use substrate_fixed:: types:: I96F32 ;
3232use subtensor_runtime_common:: { AlphaBalance , NetUid , TaoBalance , Token } ;
33+ use subtensor_swap_interface:: SwapHandler ;
3334
3435type HashingOf < T > = <T as frame_system:: Config >:: Hashing ;
3536
@@ -989,3 +990,335 @@ fn regression_basket_swapped_event_index_is_appended() {
989990 assert_eq ! ( prior_tail[ 0 ] , 148 ) ;
990991 assert_eq ! ( swapped[ 0 ] , 149 ) ;
991992}
993+
994+ // =============================================================================
995+ // Documented weaknesses (calibration pass on PR #3150)
996+ //
997+ // These tests pin *current* behaviour so the weaknesses are visible in CI. Each is
998+ // expected to PASS today; when a fix for the referenced finding lands, the assertion it
999+ // names will flip and the test must be inverted or removed together with the fix.
1000+ // Finding numbers refer to `docs/pr-3150-swap-basket-exploit-calibration.md` (§2.x, §4).
1001+ // =============================================================================
1002+
1003+ /// Deep-cash fund plus one thin subnet C (1 000 τ / 100 000 α, price 0.01, EMA = spot),
1004+ /// with enough networks on chain for the 1/16 concentration cap to bind.
1005+ fn setup_cash_fund_with_thin_pool ( ) -> ( Fund , NetUid ) {
1006+ let coldkey = U256 :: from ( 1001 ) ;
1007+ let hotkey = U256 :: from ( 1002 ) ;
1008+ let staker = U256 :: from ( 1003 ) ;
1009+ let owner_c = U256 :: from ( 3001 ) ;
1010+ let hotkey_c = U256 :: from ( 3002 ) ;
1011+
1012+ let netuid_a = add_dynamic_network ( & hotkey, & coldkey) ;
1013+ let netuid_c = add_dynamic_network ( & hotkey_c, & owner_c) ;
1014+ remove_owner_registration_stake ( netuid_a) ;
1015+ remove_owner_registration_stake ( netuid_c) ;
1016+ fund_pool ( netuid_a) ;
1017+ SubnetMovingPrice :: < Test > :: insert ( netuid_a, I96F32 :: from_num ( 1 ) ) ;
1018+ // Thin pool: 1 000 τ against 100 000 α.
1019+ SubnetTAO :: < Test > :: insert ( netuid_c, TaoBalance :: from ( THIN_POOL_TAO ) ) ;
1020+ SubnetAlphaIn :: < Test > :: insert ( netuid_c, AlphaBalance :: from ( THIN_POOL_ALPHA ) ) ;
1021+ SubnetMovingPrice :: < Test > :: insert ( netuid_c, I96F32 :: from_num ( 0.01 ) ) ;
1022+
1023+ SubtensorModule :: set_tao_weight ( u64:: MAX ) ;
1024+ zero_claim_threshold ( ) ;
1025+ register_on_root ( & hotkey, 0 ) ;
1026+ NetworksAdded :: < Test > :: insert ( NetUid :: ROOT , true ) ;
1027+ // 16 destinations on chain so `RootWeightsCap` (1/16) is enforced.
1028+ for raw in 100u16 ..113 {
1029+ NetworksAdded :: < Test > :: insert ( NetUid :: from ( raw) , true ) ;
1030+ }
1031+ assert ! (
1032+ SubtensorModule :: binding_root_weights_cap(
1033+ SubtensorModule :: get_all_subnet_netuids( ) . len( ) as u64
1034+ )
1035+ . is_some( )
1036+ ) ;
1037+
1038+ // The fund holds 100 000 τ of cash in the root slot, backed by balance on the root pot
1039+ // and counted in the root reserves; shares are outstanding so NAV/share is 1.
1040+ let escrow = SubtensorModule :: get_beta_escrow_account_id ( ) ;
1041+ let root_account = SubtensorModule :: get_subnet_account_id ( NetUid :: ROOT ) . unwrap ( ) ;
1042+ add_balance_to_coldkey_account ( & root_account, TaoBalance :: from ( CASH_NAV ) ) ;
1043+ SubtensorModule :: increase_stake_for_hotkey_and_coldkey_on_subnet (
1044+ & hotkey,
1045+ & escrow,
1046+ NetUid :: ROOT ,
1047+ CASH_NAV . into ( ) ,
1048+ ) ;
1049+ SubnetTAO :: < Test > :: mutate ( NetUid :: ROOT , |t| * t = t. saturating_add ( CASH_NAV . into ( ) ) ) ;
1050+ SubnetAlphaOut :: < Test > :: mutate ( NetUid :: ROOT , |t| * t = t. saturating_add ( CASH_NAV . into ( ) ) ) ;
1051+ TotalStake :: < Test > :: mutate ( |t| * t = t. saturating_add ( CASH_NAV . into ( ) ) ) ;
1052+ BasketShares :: < Test > :: insert ( hotkey, CASH_NAV ) ;
1053+ mock_increase_stake_for_hotkey_and_coldkey_on_subnet (
1054+ & hotkey,
1055+ & staker,
1056+ NetUid :: ROOT ,
1057+ 2_000_000u64 . into ( ) ,
1058+ ) ;
1059+
1060+ BasketTradingEnabled :: < Test > :: put ( true ) ;
1061+ (
1062+ Fund {
1063+ coldkey,
1064+ hotkey,
1065+ staker,
1066+ netuid_a,
1067+ netuid_b : netuid_c,
1068+ } ,
1069+ netuid_c,
1070+ )
1071+ }
1072+
1073+ /// 100 000 τ of fund cash.
1074+ const CASH_NAV : u64 = 100_000_000_000_000 ;
1075+ /// Thin pool reserves: 1 000 τ and 100 000 α (price 0.01).
1076+ const THIN_POOL_TAO : u64 = 1_000_000_000_000 ;
1077+ const THIN_POOL_ALPHA : u64 = 100_000_000_000_000 ;
1078+ /// One buy slice of 9 τ: < 1% of the thin pool's TAO, so its own price impact is < 2%.
1079+ const SLICE : u64 = 9_000_000_000 ;
1080+
1081+ fn spot ( netuid : NetUid ) -> f64 {
1082+ <Test as crate :: Config >:: SwapInterface :: current_alpha_price ( netuid. into ( ) ) . to_num :: < f64 > ( )
1083+ }
1084+
1085+ /// A counterparty sells (fee-free) exactly enough alpha into `netuid` to bring spot back
1086+ /// down to `target_price`, pulling the fund's TAO out of the pool.
1087+ fn counterparty_sells_back_to ( netuid : NetUid , target_price : f64 ) {
1088+ let tao = SubnetTAO :: < Test > :: get ( netuid) . to_u64 ( ) as f64 ;
1089+ let alpha_in = SubnetAlphaIn :: < Test > :: get ( netuid) . to_u64 ( ) as f64 ;
1090+ // Constant product: k = tao * alpha; at the target price alpha' = sqrt(k / p).
1091+ let target_alpha = ( tao * alpha_in / target_price) . sqrt ( ) ;
1092+ let sell = target_alpha - alpha_in;
1093+ if sell < 1.0 {
1094+ return ;
1095+ }
1096+ let out = SubtensorModule :: swap_alpha_for_tao (
1097+ netuid,
1098+ AlphaBalance :: from ( sell as u64 ) ,
1099+ <Test as crate :: Config >:: SwapInterface :: min_price :: < TaoBalance > ( ) ,
1100+ true ,
1101+ )
1102+ . expect ( "counterparty sale fills" ) ;
1103+ // The counterparty walks away with the TAO (leaves the pot).
1104+ assert_ok ! ( SubtensorModule :: transfer_tao_from_subnet(
1105+ netuid,
1106+ & U256 :: from( 9_999 ) ,
1107+ out. amount_paid_out. into( ) ,
1108+ ) ) ;
1109+ }
1110+
1111+ /// Finding §2.1 (High): sliced buys into a thin pool, each answered by a counterparty
1112+ /// sell-back to the EMA, pass every guardrail on every trade and drain the fund by ~8% of
1113+ /// NAV inside one turnover window. The concentration cap never fires because it measures
1114+ /// *realizable* value, which is bounded by the pool's TAO reserve. Documents current
1115+ /// behaviour; flip when a liquidity-relative destination cap (§5.1) lands.
1116+ #[ test]
1117+ fn finding_2_1_thin_pool_drain_passes_every_guardrail ( ) {
1118+ new_test_ext ( 1 ) . execute_with ( || {
1119+ let ( fund, netuid_c) = setup_cash_fund_with_thin_pool ( ) ;
1120+ let nav_before = nav ( & fund. hotkey ) ;
1121+ assert_eq ! ( nav_before, CASH_NAV ) ;
1122+ let budget = SubtensorModule :: basket_trade_budget_tao ( nav_before) ;
1123+ let counterparty_before = SubtensorModule :: get_coldkey_balance ( & U256 :: from ( 9_999 ) ) . to_u64 ( ) ;
1124+
1125+ let mut trades = 0u32 ;
1126+ let mut spent = 0u64 ;
1127+ let refused_with = loop {
1128+ match swap ( & fund, NetUid :: ROOT , netuid_c, SLICE ) {
1129+ Ok ( _) => {
1130+ trades += 1 ;
1131+ spent += SLICE ;
1132+ counterparty_sells_back_to ( netuid_c, 0.01 ) ;
1133+ }
1134+ Err ( err) => break err. error ,
1135+ }
1136+ } ;
1137+
1138+ // Only the turnover budget ever stops the loop, and only after it is spent.
1139+ assert_eq ! (
1140+ refused_with,
1141+ Error :: <Test >:: BasketTurnoverBudgetExceeded . into( )
1142+ ) ;
1143+ assert ! ( trades > 500 , "trades = {trades}" ) ;
1144+ assert ! ( spent > budget * 9 / 10 , "spent {spent} of budget {budget}" ) ;
1145+
1146+ // The fund now holds several times the pool's whole alpha reserve, realizable for
1147+ // roughly the pool's TAO reserve; the counterparty walked away with ~all the TAO.
1148+ let holding = escrow_alpha ( & fund. hotkey , netuid_c) ;
1149+ assert ! (
1150+ holding > 5 * THIN_POOL_ALPHA ,
1151+ "holding {holding} vs reserve {THIN_POOL_ALPHA}"
1152+ ) ;
1153+ let realizable = SubtensorModule :: realizable_tao_for_alpha ( netuid_c, holding) ;
1154+ assert ! ( realizable <= THIN_POOL_TAO ) ;
1155+ let received =
1156+ SubtensorModule :: get_coldkey_balance ( & U256 :: from ( 9_999 ) ) . to_u64 ( ) - counterparty_before;
1157+ assert ! (
1158+ received > spent * 95 / 100 ,
1159+ "counterparty took {received} of {spent}"
1160+ ) ;
1161+
1162+ // Loss: > 5% of NAV in one window (calibration run: 8.3%), i.e. ~90% of the TAO
1163+ // traded — far above the PR's stated worst case of cap × (2 × 2% + fees).
1164+ let nav_after = nav ( & fund. hotkey ) ;
1165+ let loss = nav_before - nav_after;
1166+ assert ! (
1167+ loss > nav_before * 5 / 100 ,
1168+ "loss {loss} ({}%) must exceed 5% of NAV to reproduce the finding" ,
1169+ loss * 100 / nav_before
1170+ ) ;
1171+ assert ! ( loss > spent * 3 / 4 , "loss {loss} vs spent {spent}" ) ;
1172+
1173+ // The concentration cap passes because the destination is measured realizable.
1174+ let cap = SubtensorModule :: binding_root_weights_cap (
1175+ SubtensorModule :: get_all_subnet_netuids ( ) . len ( ) as u64 ,
1176+ )
1177+ . expect ( "cap binds" ) ;
1178+ assert ! ( SubtensorModule :: share_within_root_cap(
1179+ realizable, nav_after, cap
1180+ ) ) ;
1181+ // ...while the same holding marked at spot is several multiples of the cap share.
1182+ let spot_value = SubtensorModule :: spot_tao_for_alpha ( netuid_c, holding) ;
1183+ assert ! ( !SubtensorModule :: share_within_root_cap(
1184+ spot_value, nav_after, cap
1185+ ) ) ;
1186+ assert ! ( spot_value > 5 * realizable) ;
1187+ } ) ;
1188+ }
1189+
1190+ /// Finding §2.2 (Medium): the turnover window is a fixed interval with a lazy reset, so a
1191+ /// trader can spend one full budget at block `start + 7199` and another at `start + 7200`
1192+ /// — 2× the daily cap in two adjacent blocks. Documents current behaviour; flip when a
1193+ /// token-bucket budget (§5.2) lands.
1194+ #[ test]
1195+ fn finding_2_2_window_boundary_lets_two_budgets_through_in_adjacent_blocks ( ) {
1196+ new_test_ext ( 1 ) . execute_with ( || {
1197+ let fund = setup_fund ( ) ;
1198+ BasketDailyTurnoverCap :: < Test > :: put ( DEFAULT_BASKET_DAILY_TURNOVER_CAP ) ;
1199+ let budget = SubtensorModule :: basket_trade_budget_tao ( nav ( & fund. hotkey ) ) ;
1200+ // Two half-budget trades fit inside one window (tao_mid trails alpha by the fee).
1201+ let half = budget / 2 ;
1202+
1203+ let start = System :: block_number ( ) ;
1204+ assert_ok ! ( swap( & fund, fund. netuid_a, fund. netuid_b, half) ) ;
1205+ assert_ok ! ( swap( & fund, fund. netuid_a, fund. netuid_b, half) ) ;
1206+ let ( _, used_in_first_window) = BasketTradeWindow :: < Test > :: get ( fund. hotkey ) ;
1207+ assert ! ( used_in_first_window > budget * 99 / 100 ) ;
1208+
1209+ // Last block of the window: the budget is spent.
1210+ System :: set_block_number ( start + BASKET_TRADE_WINDOW_BLOCKS - 1 ) ;
1211+ assert_noop ! (
1212+ swap( & fund, fund. netuid_a, fund. netuid_b, half) ,
1213+ Error :: <Test >:: BasketTurnoverBudgetExceeded
1214+ ) ;
1215+
1216+ // Very next block: a whole new budget is available.
1217+ System :: set_block_number ( start + BASKET_TRADE_WINDOW_BLOCKS ) ;
1218+ assert_ok ! ( swap( & fund, fund. netuid_a, fund. netuid_b, half) ) ;
1219+ assert_ok ! ( swap( & fund, fund. netuid_a, fund. netuid_b, half) ) ;
1220+ let ( _, used_in_second_window) = BasketTradeWindow :: < Test > :: get ( fund. hotkey ) ;
1221+
1222+ let moved_in_two_blocks = used_in_first_window + used_in_second_window;
1223+ assert ! (
1224+ moved_in_two_blocks > budget * 19 / 10 ,
1225+ "moved {moved_in_two_blocks} vs one budget {budget}"
1226+ ) ;
1227+ } ) ;
1228+ }
1229+
1230+ /// Finding §2.3 (Low): the 2% band is per leg, not per block. With spot below the EMA,
1231+ /// chained buy legs in one block each pass (`ceiling = 1.02 × min(EMA, spot)`) and walk
1232+ /// the price up to 1.02 × EMA — here +20% or more in a single block. Documents current
1233+ /// behaviour; flip if a per-block price or rate bound is added.
1234+ #[ test]
1235+ fn finding_2_3_chained_legs_in_one_block_walk_price_to_ema_ceiling ( ) {
1236+ new_test_ext ( 1 ) . execute_with ( || {
1237+ let ( fund, netuid_c) = setup_cash_fund_with_thin_pool ( ) ;
1238+ // Spot 0.01 sits 20% below a 0.0125 EMA.
1239+ let ema = 0.0125f64 ;
1240+ SubnetMovingPrice :: < Test > :: insert ( netuid_c, I96F32 :: from_num ( ema) ) ;
1241+ let spot_before = spot ( netuid_c) ;
1242+ let block = System :: block_number ( ) ;
1243+
1244+ let mut legs = 0u32 ;
1245+ let refused_with = loop {
1246+ match swap ( & fund, NetUid :: ROOT , netuid_c, SLICE ) {
1247+ Ok ( _) => legs += 1 ,
1248+ Err ( err) => break err. error ,
1249+ }
1250+ } ;
1251+ assert_eq ! ( System :: block_number( ) , block, "all legs ran in one block" ) ;
1252+ assert_eq ! ( refused_with, Error :: <Test >:: SlippageTooHigh . into( ) ) ;
1253+
1254+ let spot_after = spot ( netuid_c) ;
1255+ assert ! ( legs >= 10 , "legs = {legs}" ) ;
1256+ assert ! (
1257+ spot_after / spot_before > 1.20 ,
1258+ "price moved {spot_before} -> {spot_after} in one block"
1259+ ) ;
1260+ // Stopped only by the EMA ceiling, not by any per-block rule.
1261+ assert ! ( spot_after <= ema * 1.02 * 1.001 ) ;
1262+ assert ! ( spot_after > ema) ;
1263+ } ) ;
1264+ }
1265+
1266+ /// Finding §4 "crash lock": once spot sits 5% below the EMA a fund cannot sell even one
1267+ /// unit of the holding (no stop-loss), and 5% above it cannot buy. Documents current
1268+ /// behaviour; flip when a wider sell-side tolerance (§5.3) lands.
1269+ #[ test]
1270+ fn finding_crash_lock_blocks_selling_a_falling_holding ( ) {
1271+ new_test_ext ( 1 ) . execute_with ( || {
1272+ let fund = setup_fund ( ) ;
1273+ // Spot is 1.0 on both pools. A's EMA is 5% above spot: the fund holds A and cannot
1274+ // sell any of it — into another subnet or into cash.
1275+ SubnetMovingPrice :: < Test > :: insert ( fund. netuid_a , I96F32 :: from_num ( 1.0 / 0.95 ) ) ;
1276+ assert_noop ! (
1277+ swap( & fund, fund. netuid_a, fund. netuid_b, TRADE ) ,
1278+ Error :: <Test >:: SlippageTooHigh
1279+ ) ;
1280+ assert_noop ! (
1281+ swap( & fund, fund. netuid_a, NetUid :: ROOT , TRADE ) ,
1282+ Error :: <Test >:: SlippageTooHigh
1283+ ) ;
1284+ SubnetMovingPrice :: < Test > :: insert ( fund. netuid_a , I96F32 :: from_num ( 1 ) ) ;
1285+
1286+ // B's EMA is 5% below spot: the fund cannot buy B.
1287+ SubnetMovingPrice :: < Test > :: insert ( fund. netuid_b , I96F32 :: from_num ( 0.95 ) ) ;
1288+ assert_noop ! (
1289+ swap( & fund, fund. netuid_a, fund. netuid_b, TRADE ) ,
1290+ Error :: <Test >:: SlippageTooHigh
1291+ ) ;
1292+ } ) ;
1293+ }
1294+
1295+ /// Finding §4 "cash slot": root is a capped destination like any subnet, so with the cap
1296+ /// binding a fund cannot hold more than `RootWeightsCap` (1/16) of NAV as TAO — it cannot
1297+ /// de-risk into cash. Documents current behaviour; flip when a separate cash cap (§5.4)
1298+ /// lands.
1299+ #[ test]
1300+ fn finding_cash_slot_is_capped_at_root_weights_cap ( ) {
1301+ new_test_ext ( 1 ) . execute_with ( || {
1302+ let fund = setup_fund ( ) ;
1303+ // Root + A + B + 13 placeholders = 16 destinations: the default 1/16 cap binds.
1304+ for raw in 100u16 ..113 {
1305+ NetworksAdded :: < Test > :: insert ( NetUid :: from ( raw) , true ) ;
1306+ }
1307+ let available = SubtensorModule :: get_all_subnet_netuids ( ) . len ( ) as u64 ;
1308+ assert_eq ! ( available, 16 ) ;
1309+ assert_eq ! (
1310+ SubtensorModule :: binding_root_weights_cap( available) ,
1311+ Some ( u64 :: from( RootWeightsCap :: <Test >:: get( NetUid :: ROOT ) ) )
1312+ ) ;
1313+
1314+ let held = escrow_alpha ( & fund. hotkey , fund. netuid_a ) ;
1315+ // 10% of the fund into cash: refused.
1316+ assert_noop ! (
1317+ swap( & fund, fund. netuid_a, NetUid :: ROOT , held / 10 ) ,
1318+ Error :: <Test >:: RootWeightCapExceeded
1319+ ) ;
1320+ // 6% (just under 1/16): allowed.
1321+ assert_ok ! ( swap( & fund, fund. netuid_a, NetUid :: ROOT , held * 6 / 100 ) ) ;
1322+ assert ! ( escrow_alpha( & fund. hotkey, NetUid :: ROOT ) > 0 ) ;
1323+ } ) ;
1324+ }
0 commit comments