From 976def52d11f3250119e6cced5fc29f8692c667d Mon Sep 17 00:00:00 2001 From: unarbos Date: Tue, 4 Aug 2026 15:55:37 -0300 Subject: [PATCH] fix(subtensor): price basket deposits in alpha space; re-enable stake_into_basket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the state-growth review of `stake_into_basket` (gated in #3042) and re-enables the call with a corrected deposit share-pricing model. Redemption redeems a fraction of every fund holding (physical alpha), while the mint previously priced deposits against the fund's TAO/NAV mark. On a concave AMM curve those units disagree: a deposit's ΔNAV re-marks existing alpha up by exactly the buy's own slippage, so a deposit-then-claim round trip could settle for more than it deposited. This reworks the mint to price in the same physical-alpha space a claim redeems in: - User deposits mirror the fund once shares exist: TAO is deployed pro-rata across current value-bearing holdings, so a deposit buys exactly the pro-rata basket a later claim sells. The first deposit (no holders yet) still follows the curated weight vector. - Shares mint as `P * min_i(bought_i / pre_alpha_i)` over the mirrored holdings, computed with an exact flooring `mul_div_u64`. This bounds the redeemable fraction so a claim returns at most the alpha the deposit itself added; any cross-slot slack is donated to existing holders. Par (first deposit) and drained-fund revival keep the NAV/par rule. Existing basket tests are unchanged in behavior; adds two round-trip guards (small fractional deposit into a large discounted position, and a large deposit) asserting a deposit->claim cannot profit and cannot shrink an existing holder's position. Bumps spec_version 443 -> 444. Co-authored-by: Cursor --- pallets/subtensor/src/macros/dispatches.rs | 9 +- pallets/subtensor/src/staking/claim_root.rs | 166 ++++++++++---- .../subtensor/src/tests/stake_into_basket.rs | 202 ++++++++++++++++-- runtime/src/lib.rs | 2 +- 4 files changed, 305 insertions(+), 74 deletions(-) diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 11aacadc6e..f4c82ddcec 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2009,14 +2009,9 @@ mod dispatches { hotkey: T::AccountId, amount_staked: TaoBalance, ) -> DispatchResultWithPostInfo { - // Temporarily gated pending a state-growth review: direct deposits open one - // escrow holding row per weight slot, so the per-call storage footprint is - // being reassessed before the path is re-enabled. let coldkey = ensure_signed(origin)?; - log::debug!( - "stake_into_basket gated (c={coldkey:?} h={hotkey:?} amt={amount_staked:?})" - ); - Err(Error::::CallDisabled.into()) + let weight = Self::do_stake_into_basket(coldkey, hotkey, amount_staked)?; + Ok((Some(weight), Pays::Yes).into()) } // Call indices 122 (`set_root_claim_type`) and 123 (`sudo_set_num_root_claims`) are diff --git a/pallets/subtensor/src/staking/claim_root.rs b/pallets/subtensor/src/staking/claim_root.rs index ff277c8e19..24bec63a33 100644 --- a/pallets/subtensor/src/staking/claim_root.rs +++ b/pallets/subtensor/src/staking/claim_root.rs @@ -217,8 +217,9 @@ impl Pallet { Ok(()) } - /// The shared basket deployment engine: splits `tao` across the validator's weight vector - /// `valid` (last slot absorbs the rounding remainder so the split sums exactly), buys each + /// The shared basket deployment engine: splits `tao` across the slots in `valid` (the + /// validator weight vector for dividend deposits, the fund's own mirrored holdings for user + /// deposits; last slot absorbs the rounding remainder so the split sums exactly), buys each /// subnet slot's alpha into the escrow position, and holds root-slot slices as root stake /// (TAO at 1:1, mirroring `swap_tao_for_alpha`'s reserve bookkeeping by hand). Each buy is /// booked as protocol inflow: claims book the escrow's sells as outflow regardless of how @@ -331,29 +332,32 @@ impl Pallet { Ok((nav_before, nav_after.saturating_sub(nav_before))) } - /// Stakes `tao` from `coldkey`'s free balance directly into a validator's basket: - /// the TAO is deployed across subnets per the validator's root weight vector (exactly - /// like a dividend deposit), and the resulting fund shares are credited to the staker - /// through their signed claimed watermark — `owed = rate * root_stake - claimed`, so a - /// negative watermark credit is an unconditional share grant that needs no root stake - /// and survives stake-change rebasing (which is additive). + /// Stakes `tao` from `coldkey`'s free balance directly into a validator's basket, and + /// credits the resulting fund shares to the staker through their signed claimed watermark + /// — `owed = rate * root_stake - claimed`, so a negative watermark credit is an + /// unconditional share grant that needs no root stake and survives stake-change rebasing + /// (which is additive). /// - /// An uncurated fund (no usable weight vector — dividends accumulate in place) has no - /// vector to deploy a TAO deposit across, so the deposit *mirrors the fund*: it is - /// deployed pro-rata across the current holdings by realizable value. A deposit then - /// buys exactly the exposure the minted shares represent, existing holders' composition - /// is untouched, and the deposit-then-claim round trip stays symmetric with redemption - /// (claims redeem pro-rata of every holding) — without this, cycling cash deposits - /// through claims would let anyone convert an uncurated fund's alpha into cash and push - /// sell pressure through the escrow. An empty fund has nothing to mirror; that deposit - /// is held as the fund's root (TAO cash) slot at NAV. + /// Once shares exist the deposit *mirrors the fund*: the TAO is deployed pro-rata across + /// the current value-bearing holdings by realizable value (worthless rows carry no + /// weight), so it buys exactly the pro-rata basket a later claim will sell (claims redeem + /// a fraction of every holding). Deploying instead across the validator weight vector — + /// which need not match current holdings — would let a claim redeem holdings the deposit + /// never funded; the weight vector governs dividend deployment only, not user cash entry. + /// The very first deposit has no holders to protect and nothing to mirror, so it follows + /// the fund's intended exposure (the curated weight vector) like a dividend would; an + /// uncurated or empty fund with no vector holds the deposit as the fund's root (TAO cash) + /// slot. /// - /// Shares are minted at the pre-buy realizable NAV against the realizable value the - /// deposit added (`nav_after - nav_before`), so the depositor bears their own entry - /// slippage and fees, and a deposit-then-claim round trip nets to ~0 (minus swap fees) - /// at any basket size. Unlike dividend deposits there is no attribution split: the - /// whole deposit belongs to the depositor. `BasketRate` is untouched — direct shares - /// buy fund exposure, they do not change any staker's dividend accrual. + /// Shares are minted in physical-alpha space — `P * min_i(bought_i / pre_alpha_i)` over + /// the mirrored holdings (see `try_stake_into_basket`) — so a deposit's shares redeem to + /// at most the alpha the deposit itself added to every slot. The depositor bears their own + /// entry slippage and fees, a deposit-then-claim round trip can never extract an existing + /// holder's value at any basket size, and any cross-slot slack is donated to existing + /// holders. First deposits (par) and drained-fund revival keep the NAV/par rule. Unlike + /// dividend deposits there is no attribution split: the whole deposit belongs to the + /// depositor. `BasketRate` is untouched — direct shares buy fund exposure, they do not + /// change any staker's dividend accrual. pub fn do_stake_into_basket( coldkey: T::AccountId, hotkey: T::AccountId, @@ -373,27 +377,38 @@ impl Pallet { Error::::NotEnoughBalanceToStake ); - let mut valid = Self::get_valid_basket_weights(&hotkey); - if valid.is_empty() { - // Uncurated fund: mirror the fund — deploy pro-rata across current holdings by - // realizable value (worthless rows carry no weight). Empty fund: nothing to - // mirror, hold the deposit as the fund's root (TAO cash) slot. - valid = Self::get_basket_holdings(&hotkey) - .into_iter() + // Once shares exist, a deposit must mirror the fund: deploy pro-rata across the + // current value-bearing holdings by realizable value (worthless rows carry no weight), + // so it buys exactly the pro-rata basket a later claim sells (redemption redeems a + // fraction of *every* holding). That symmetry is what makes the physical-alpha mint in + // `try_stake_into_basket` extraction-proof: the deposit can only buy the exposure its + // shares will later redeem. Deploying across the validator weight vector instead — + // which need not match current holdings — would let a claim redeem holdings the + // deposit never funded, the core extraction vector. + // + // Before any shares exist there are no holders to protect and nothing to mirror, so + // the first deposit follows the fund's intended exposure (the curated weight vector) + // exactly as a dividend deposit would. An uncurated or empty fund has no vector and no + // holdings, so it falls through to the root (TAO cash) slot. + let holdings = Self::get_basket_holdings(&hotkey); + let mut valid: Vec<(NetUid, u64)> = if BasketShares::::get(&hotkey) == 0 { + Self::get_valid_basket_weights(&hotkey) + } else { + holdings + .iter() .filter_map(|(netuid, alpha)| { - let value = Self::realizable_tao_for_alpha(netuid, alpha.to_u64()); - (value > 0).then_some((netuid, value)) + let value = Self::realizable_tao_for_alpha(*netuid, alpha.to_u64()); + (value > 0).then_some((*netuid, value)) }) - .collect(); - if valid.is_empty() { - valid = vec![(NetUid::ROOT, 1)]; - } + .collect() + }; + if valid.is_empty() { + valid = vec![(NetUid::ROOT, 1)]; } - // Each weight slot can add at most one new holding, so pre-deploy holdings plus the - // slot count bounds the holdings the two NAV valuations will sweep. - let num_holdings = - (Self::get_basket_holdings(&hotkey).len() as u64).saturating_add(valid.len() as u64); + // Each slot deploys into at most one holding (mirror reuses existing ones), so + // pre-deploy holdings plus the slot count bounds the holdings the NAV valuations sweep. + let num_holdings = (holdings.len() as u64).saturating_add(valid.len() as u64); with_transaction( || match Self::try_stake_into_basket(&coldkey, &hotkey, tao, &valid) { @@ -417,10 +432,24 @@ impl Pallet { valid: &[(NetUid, u64)], ) -> DispatchResult { let shares_outstanding: u64 = BasketShares::::get(hotkey); + let escrow = Self::get_beta_escrow_account_id(); + + // Snapshot each mirrored slot's escrow alpha before the buys so the mint below can be + // priced in the same physical-alpha units a claim later redeems in. + let pre_alpha: Vec<(NetUid, u64)> = valid + .iter() + .map(|(netuid, _)| { + ( + *netuid, + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, *netuid) + .to_u64(), + ) + }) + .collect(); - // Deploy the staker's TAO across the basket per the weight vector. ΔNAV valuation - // means the depositor bears their own entry slippage/fees and cannot capture value - // beyond the TAO they brought. + // Deploy the staker's TAO across the mirrored holdings. `value_added` (ΔNAV) still + // meters the deposit's realizable contribution for the par/revival paths, the event, + // and the deposited-TAO counter; the depositor bears their own entry slippage/fees. let (nav_before, value_added) = Self::deploy_tao_into_basket( hotkey, valid, @@ -428,8 +457,55 @@ impl Pallet { BasketFunding::User(coldkey), )?; - let shares: u64 = - Self::basket_shares_for_value(value_added, nav_before, shares_outstanding); + // Share mint. A claim redeems `owed / P` of *every* holding, so to guarantee a + // deposit->claim round trip can never pull an existing holder's alpha, the mint is + // priced in physical-alpha space rather than against the TAO/NAV mark: + // + // shares = P * min_i( bought_i / pre_alpha_i ) over the mirrored holdings. + // + // With redeemable fraction `f = shares / (P + shares)`, this bounds `f` so that + // `f * (pre_alpha_i + bought_i) <= bought_i` for every slot — a claim returns at most + // the alpha the deposit itself added, never existing holders'. Valuing against NAV + // instead over-credits (on a concave AMM curve ΔNAV re-marks existing alpha up by + // exactly the buy's own slippage), which is the extraction vector this replaces. The + // `min` donates any cross-slot slack to existing holders — the safe direction. + // + // The par (first deposit, `P == 0`) and drained-fund revival (`nav_before == 0`) + // cases have no live per-share alpha to price against, so they keep the NAV/par rule. + let shares: u64 = if shares_outstanding == 0 || nav_before == 0 { + Self::basket_shares_for_value(value_added, nav_before, shares_outstanding) + } else { + // Pick the slot with the smallest fill ratio `bought_i / pre_alpha_i`, comparing + // the fractions cross-multiplied in u128 (no rounding), then mint + // `P * bought_min / pre_min` with a single flooring `mul_div_u64`. Flooring is the + // safe (under-crediting) direction and stays bit-exact for a single-slot fund, so + // an all-cash round trip nets to zero to the rao. + let mut min_fill: Option<(u64, u64)> = None; + for (netuid, pre) in pre_alpha.iter() { + if *pre == 0 { + // A holding the deposit newly created: there is no pre-existing alpha for + // a claim to extract, so it never constrains the fill ratio. + continue; + } + let post = + Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, *netuid) + .to_u64(); + let bought = post.saturating_sub(*pre); + min_fill = Some(match min_fill { + Some((cur_bought, cur_pre)) + if u128::from(cur_bought).saturating_mul(u128::from(*pre)) + <= u128::from(bought).saturating_mul(u128::from(cur_pre)) => + { + (cur_bought, cur_pre) + } + _ => (bought, *pre), + }); + } + match min_fill { + Some((bought, pre)) => Self::mul_div_u64(shares_outstanding, bought, pre), + None => 0, + } + }; ensure!(shares > 0, Error::::AmountTooLow); BasketShares::::mutate(hotkey, |p| *p = p.saturating_add(shares)); diff --git a/pallets/subtensor/src/tests/stake_into_basket.rs b/pallets/subtensor/src/tests/stake_into_basket.rs index a265d1d1e7..f9fa37359c 100644 --- a/pallets/subtensor/src/tests/stake_into_basket.rs +++ b/pallets/subtensor/src/tests/stake_into_basket.rs @@ -64,8 +64,8 @@ fn test_stake_into_basket_round_trip_symmetric() { let amount = 10_000_000u64; add_balance_to_coldkey_account(&bob, TaoBalance::from(2 * amount)); - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, amount.into(), )); @@ -120,8 +120,8 @@ fn test_stake_into_basket_empty_fund_par_mint_equals_nav() { let amount = 10_000_000u64; add_balance_to_coldkey_account(&bob, TaoBalance::from(2 * amount)); - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, amount.into(), )); @@ -190,8 +190,8 @@ fn test_stake_into_basket_does_not_dilute_existing_holders() { // Bob (no root stake at all) buys in directly. let amount = 10_000_000u64; add_balance_to_coldkey_account(&bob, TaoBalance::from(2 * amount)); - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, amount.into(), )); @@ -233,21 +233,29 @@ fn test_stake_into_basket_rejections() { // Hotkey with no account. assert_noop!( - SubtensorModule::do_stake_into_basket(bob, U256::from(777), 10_000_000u64.into(),), + SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), + U256::from(777), + 10_000_000u64.into(), + ), Error::::HotKeyAccountNotExists ); // Below the minimum stake. let dust = DefaultMinStake::::get().to_u64().saturating_sub(1); assert_noop!( - SubtensorModule::do_stake_into_basket(bob, hotkey, dust.into(),), + SubtensorModule::stake_into_basket(RuntimeOrigin::signed(bob), hotkey, dust.into(),), Error::::AmountTooLow ); // No balance. let pauper = U256::from(2002); assert_noop!( - SubtensorModule::do_stake_into_basket(pauper, hotkey, 10_000_000u64.into(),), + SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(pauper), + hotkey, + 10_000_000u64.into(), + ), Error::::NotEnoughBalanceToStake ); @@ -257,8 +265,8 @@ fn test_stake_into_basket_rejections() { set_root_weights_direct(&hotkey, 0, &[(NetUid::from(99u16), u16::MAX)]); let escrow = SubtensorModule::get_beta_escrow_account_id(); let deposit = 10_000_000u64; - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, deposit.into(), )); @@ -286,8 +294,8 @@ fn test_stake_into_basket_rejections() { deposit.into(), ); let alpha_before = escrow_alpha(&hotkey, netuid); - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, deposit.into(), )); @@ -345,8 +353,8 @@ fn test_stake_into_basket_credit_survives_stake_changes() { // Bob buys in directly with zero root stake. let amount = 10_000_000u64; add_balance_to_coldkey_account(&bob, TaoBalance::from(2 * amount)); - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, amount.into(), )); @@ -430,8 +438,8 @@ fn test_stake_into_basket_gets_no_dividend_accrual() { let amount = 10_000_000u64; add_balance_to_coldkey_account(&bob, TaoBalance::from(2 * amount)); - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, amount.into(), )); @@ -577,8 +585,8 @@ fn test_root_slot_yield_accrues_to_share_holders() { // Bob buys in directly: all-root basket at N/P = 1, so his TAO mints 1:1 exactly. let b = 10_000_000u64; add_balance_to_coldkey_account(&bob, TaoBalance::from(2 * b)); - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, b.into(), )); @@ -688,8 +696,8 @@ fn test_stake_into_basket_cannot_skim_compounding() { // his immediate payout is ~his TAO — none of alice's compounding. let amount = 10_000_000u64; add_balance_to_coldkey_account(&bob, TaoBalance::from(2 * amount)); - assert_ok!(SubtensorModule::do_stake_into_basket( - bob, + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(bob), hotkey, amount.into(), )); @@ -711,3 +719,155 @@ fn test_stake_into_basket_cannot_skim_compounding() { assert_shares_fully_owed(&hotkey, &[alice, bob], ROUNDING_EPS); }); } + +/// Extraction guard (the concavity skim): a fund holds a large alpha position on a *thin* +/// pool, so its full-liquidation NAV mark sits well below the position's spot value. An +/// attacker deposits, then immediately claims. Pricing the mint against that depressed NAV +/// mark (the old behavior) would credit shares whose partial redemption — which sells only a +/// small fraction of the position, at near-spot — realizes more TAO than was deposited, +/// pulling the difference out of the existing holder. The physical-alpha mint prices the +/// deposit so its shares redeem to at most the alpha it added: the round trip cannot profit +/// and the existing holder is left whole. +#[test] +fn test_stake_into_basket_cannot_extract_from_existing_holder() { + new_test_ext(1).execute_with(|| { + let (owner_coldkey, hotkey, netuid) = setup_stake_in_env(); + let alice = U256::from(2001); + let attacker = U256::from(2002); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &alice, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + // Alice becomes the sole share holder via a dividend; the escrow now holds a real + // alpha position on `netuid`. Sized so the attacker's fractional deposit below still + // clears the min-stake gate. + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 20_000_000u64.into(), + AlphaBalance::ZERO, + ); + flush_baskets(); + let held = escrow_alpha(&hotkey, netuid); + assert!(held > 0, "fund must hold an alpha position to skim"); + + // Thin the pool so the held position is large relative to reserves: a full + // liquidation now slips ~33%, opening a wide gap between the NAV mark and spot. + SubnetTAO::::insert(netuid, TaoBalance::from(2 * held)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(2 * held)); + + let alice_payout_before = SubtensorModule::get_basket_payout_tao(&hotkey, &alice); + let held_before = escrow_alpha(&hotkey, netuid); + + // Attacker deposits a fraction of the held size, then immediately claims back out. + let amount = held / 4; + add_balance_to_coldkey_account(&attacker, TaoBalance::from(2 * amount)); + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(attacker), + hotkey, + amount.into(), + )); + assert_ok!(SubtensorModule::claim_root_with_hotkey( + RuntimeOrigin::signed(attacker), + hotkey + )); + + // The round trip cannot create value: the attacker recovers at most what they put in. + let recovered = root_stake_of(&hotkey, &attacker); + assert!( + recovered <= amount, + "deposit->claim must not extract: recovered {recovered} of {amount}" + ); + + // And the existing holder is left whole — none of alice's alpha was pulled out. + let alice_payout_after = SubtensorModule::get_basket_payout_tao(&hotkey, &alice); + assert!( + alice_payout_after + ROUNDING_EPS >= alice_payout_before, + "existing holder must not be robbed: {alice_payout_after} < {alice_payout_before}" + ); + assert!( + escrow_alpha(&hotkey, netuid) + ROUNDING_EPS >= held_before, + "fund's alpha position must not shrink below its pre-attack level" + ); + }); +} + +/// Extraction guard (a large buy): an attacker whose deposit is large relative to both the +/// pool and the fund's existing position still cannot profit from an immediate deposit->claim +/// round trip, nor dilute the existing holder. The physical-alpha mint charges the attacker +/// their own price impact on the way in, which their partial redemption cannot recoup. +#[test] +fn test_stake_into_basket_large_deposit_cannot_extract() { + new_test_ext(1).execute_with(|| { + let (owner_coldkey, hotkey, netuid) = setup_stake_in_env(); + let alice = U256::from(2001); + let attacker = U256::from(2002); + + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &alice, + NetUid::ROOT, + 2_000_000u64.into(), + ); + mock_increase_stake_for_hotkey_and_coldkey_on_subnet( + &hotkey, + &owner_coldkey, + netuid, + 10_000_000u64.into(), + ); + set_root_weights_direct(&hotkey, 0, &[(netuid, u16::MAX)]); + + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + 2_000_000u64.into(), + AlphaBalance::ZERO, + ); + flush_baskets(); + let held = escrow_alpha(&hotkey, netuid); + assert!(held > 0); + + // Thin pool + a deposit several times the reserve: the attacker's own buy moves the + // price hard against them. + SubnetTAO::::insert(netuid, TaoBalance::from(held)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(held)); + + let alice_payout_before = SubtensorModule::get_basket_payout_tao(&hotkey, &alice); + + let amount = 4 * held; + add_balance_to_coldkey_account(&attacker, TaoBalance::from(2 * amount)); + assert_ok!(SubtensorModule::stake_into_basket( + RuntimeOrigin::signed(attacker), + hotkey, + amount.into(), + )); + assert_ok!(SubtensorModule::claim_root_with_hotkey( + RuntimeOrigin::signed(attacker), + hotkey + )); + + let recovered = root_stake_of(&hotkey, &attacker); + assert!( + recovered <= amount, + "large deposit->claim must not extract: recovered {recovered} of {amount}" + ); + let alice_payout_after = SubtensorModule::get_basket_payout_tao(&hotkey, &alice); + assert!( + alice_payout_after + ROUNDING_EPS >= alice_payout_before, + "existing holder must not be diluted by a large deposit: {alice_payout_after} < {alice_payout_before}" + ); + }); +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 27fe5f0c2c..36bb308c5e 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -235,7 +235,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 443, + spec_version: 444, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1,