Skip to content

Commit b1e71cb

Browse files
cursoragentunarbos
andcommitted
test: pin the swap_basket weaknesses from the PR #3150 calibration pass
Behaviour-documenting tests that pass today and must flip when a fix lands (each names its finding in docs/pr-3150-swap-basket-exploit-calibration.md): - §2.1 thin-pool drain: sliced root->C buys with counterparty sell-back pass every guardrail until the turnover budget is spent and lose >5% of NAV (~90% of the TAO traded); the concentration cap passes on realizable value while the same holding at spot is many times the cap share. - §2.2 window-boundary burst: 2x the daily budget moved in two adjacent blocks across the window edge. - §2.3 chained legs in one block walk the price >20% up to 1.02 x EMA. - crash lock: selling refused with spot 5% below EMA, buying refused 5% above; cash slot capped at RootWeightsCap (1/16) once the cap binds. - proxies: NonTransfer / NonCritical currently admit swap_basket without an explicit BasketTrading grant. Co-authored-by: Arbos <unarbos@users.noreply.github.com>
1 parent 6ec4c8e commit b1e71cb

2 files changed

Lines changed: 361 additions & 8 deletions

File tree

pallets/subtensor/src/tests/swap_basket.rs

Lines changed: 337 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ use crate::tests::claim_root::{
1515
};
1616
use crate::tests::mock::*;
1717
use 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
};
2424
use codec::Encode;
2525
use frame_support::dispatch::DispatchResultWithPostInfo;
@@ -30,6 +30,7 @@ use sp_core::U256;
3030
use sp_runtime::traits::Hash;
3131
use substrate_fixed::types::I96F32;
3232
use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token};
33+
use subtensor_swap_interface::SwapHandler;
3334

3435
type 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

Comments
 (0)