Skip to content
26 changes: 26 additions & 0 deletions pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,32 @@ mod dispatches {
)
}

/// Transfers locked or unlocked stake from one coldkey to another, within one subnet,
/// while keeping the same hotkey.
///
/// If `locked` is true, the call transfers at most the currently locked alpha and moves
/// the corresponding lock state. If `locked` is false, it transfers at most the currently
/// unlocked alpha.
#[pallet::call_index(139)]
#[pallet::weight(<T as crate::pallet::Config>::WeightInfo::transfer_stake())]
pub fn transfer_stake_lock_aware(
origin: OriginFor<T>,
destination_coldkey: T::AccountId,
hotkey: T::AccountId,
netuid: NetUid,
alpha_amount: AlphaBalance,
locked: bool,
) -> DispatchResult {
Self::do_transfer_stake_lock_aware(
origin,
destination_coldkey,
hotkey,
netuid,
alpha_amount,
locked,
)
}

/// Swaps a specified amount of stake from one subnet to another, while keeping the same coldkey and hotkey.
///
/// # Arguments
Expand Down
34 changes: 31 additions & 3 deletions pallets/subtensor/src/staking/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,20 @@ impl<T: Config> Pallet<T> {
available
}

/// Returns the transferable alpha in either the locked or unlocked bucket.
pub fn lock_aware_transferable_alpha(
coldkey: &T::AccountId,
netuid: NetUid,
locked: bool,
) -> AlphaBalance {
if locked {
Self::get_current_locked(coldkey, netuid)
.min(Self::total_coldkey_alpha_on_subnet(coldkey, netuid))
} else {
Self::available_to_unstake(coldkey, netuid)
}
}

/// Ensures that the amount can be unstaked
pub fn ensure_available_to_unstake(
coldkey: &T::AccountId,
Expand Down Expand Up @@ -1748,6 +1762,7 @@ impl<T: Config> Pallet<T> {
destination_coldkey: &T::AccountId,
netuid: NetUid,
amount: AlphaBalance,
lock_aware_transfer: Option<bool>,
) -> DispatchResult {
let now = Self::get_current_block_as_u64();

Expand Down Expand Up @@ -1796,9 +1811,22 @@ impl<T: Config> Pallet<T> {
let unavailable = source_lock.locked_mass;
let available_stake = total_alpha.saturating_sub(unavailable);

// Reduce remaining_to_transfer by min(remaining_to_transfer, available stake)
let available_transfer = remaining_to_transfer.min(available_stake);
remaining_to_transfer = remaining_to_transfer.saturating_sub(available_transfer);
// In the default mode, transfers consume unlocked stake first. Any amount
// left in `remaining_to_transfer` after this subtraction must come from
// the lock and needs lock state moved with it. In locked-only mode, skip
// this subtraction so the whole capped amount is treated as locked so
// effectively we start the transfer from the locked portion without accounting
// for unlocked alpha.
if lock_aware_transfer != Some(true) {
let available_transfer = remaining_to_transfer.min(available_stake);
remaining_to_transfer = remaining_to_transfer.saturating_sub(available_transfer);
}

// In unlocked-only mode, the capped amount has already been fully covered
// by unlocked stake, so no lock state should be transferred.
if lock_aware_transfer == Some(false) {
remaining_to_transfer = AlphaBalance::ZERO;
}

// If result is non-zero, check the hotkey match between source and destination coldkey locks
// (if destination coldkey lock exists). If no match, error out with LockHotkeyMismatch, otherwise,
Expand Down
60 changes: 60 additions & 0 deletions pallets/subtensor/src/staking/move_stake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl<T: Config> Pallet<T> {
None,
None,
false,
None,
)?;

// Log the event.
Expand Down Expand Up @@ -140,6 +141,7 @@ impl<T: Config> Pallet<T> {
None,
None,
true,
None,
)?;

// 9. Emit an event for logging/monitoring.
Expand All @@ -159,6 +161,51 @@ impl<T: Config> Pallet<T> {
Ok(())
}

/// Transfers either locked or unlocked stake from one coldkey to another within one subnet.
///
/// This follows `do_transfer_stake`, but caps the requested amount to the
/// selected lock bucket. If `locked` is true, only locked alpha can move and
/// the matching lock state follows the stake. If `locked` is false, only
/// unlocked alpha can move.
pub fn do_transfer_stake_lock_aware(
origin: OriginFor<T>,
destination_coldkey: T::AccountId,
hotkey: T::AccountId,
netuid: NetUid,
alpha_amount: AlphaBalance,
locked: bool,
) -> dispatch::DispatchResult {
let coldkey = ensure_signed(origin)?;

let tao_moved = Self::transition_stake_internal(
&coldkey,
&destination_coldkey,
&hotkey,
&hotkey,
netuid,
netuid,
alpha_amount,
None,
None,
true,
Some(locked),
Comment on lines +180 to +191

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Cross-subnet locked transfers do not move lock state

Some(locked) is passed into the generic transition path even when origin_netuid != destination_netuid, but the cross-subnet branch later uses unstake_from_subnet and stake_into_subnet rather than transfer_lock. That means no lock state is moved to the destination subnet/coldkey. Worse, validate_stake_transition still enforces ensure_available_to_unstake for cross-subnet moves, so a fully locked position fails with StakeUnavailable, while a partially locked position with enough unlocked alpha can succeed by moving unlocked stake and leaving the source lock behind. This contradicts the new extrinsic docs and the PR body claim that locked transfers preserve/move the lock state. Either reject origin_netuid != destination_netuid for this extrinsic, or implement explicit cross-subnet lock migration and add a test that locked=true moves lock state across netuids.

)?;

log::debug!(
"StakeTransferred(origin_coldkey: {coldkey:?}, destination_coldkey: {destination_coldkey:?}, hotkey: {hotkey:?}, netuid: {netuid:?}, amount: {tao_moved:?})"
);
Self::deposit_event(Event::StakeTransferred(
coldkey,
destination_coldkey,
hotkey,
netuid,
netuid,
tao_moved,
));

Ok(())
}

/// Swaps a specified amount of stake for the same `(coldkey, hotkey)` pair from one subnet
/// (`origin_netuid`) to another (`destination_netuid`).
///
Expand Down Expand Up @@ -204,6 +251,7 @@ impl<T: Config> Pallet<T> {
None,
None,
false,
None,
)?;

// Emit an event for logging.
Expand Down Expand Up @@ -271,6 +319,7 @@ impl<T: Config> Pallet<T> {
Some(limit_price),
Some(allow_partial),
false,
None,
)?;

// Emit an event for logging.
Expand Down Expand Up @@ -302,6 +351,7 @@ impl<T: Config> Pallet<T> {
maybe_limit_price: Option<TaoBalance>,
maybe_allow_partial: Option<bool>,
check_transfer_toggle: bool,
lock_aware_transfer: Option<bool>,
) -> Result<TaoBalance, DispatchError> {
// Cap the alpha_amount at available Alpha because user might be paying transaxtion fees
// in Alpha and their total is already reduced by now.
Expand All @@ -311,6 +361,15 @@ impl<T: Config> Pallet<T> {
origin_netuid,
);
let alpha_amount = alpha_amount.min(alpha_available);
let alpha_amount = lock_aware_transfer
.map(|locked| {
alpha_amount.min(Self::lock_aware_transferable_alpha(
Comment thread
gztensor marked this conversation as resolved.
Comment thread
gztensor marked this conversation as resolved.
Comment thread
gztensor marked this conversation as resolved.
Comment thread
gztensor marked this conversation as resolved.
Comment on lines +364 to +366

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Lock-aware cap is not bound to the selected hotkey

This cap is still coldkey-wide: lock_aware_transferable_alpha receives only (origin_coldkey, origin_netuid, locked), while the stake debit above is for the caller-selected (origin_hotkey, origin_coldkey, origin_netuid). transfer_lock also discovers the lock hotkey via read_conviction_model(origin_coldkey, netuid, ...) rather than the selected hotkey. If a coldkey has a lock on hotkey A and unlocked stake on hotkey B, transfer_stake_lock_aware(... hotkey=B, locked=true) can pass this cap using A's locked amount, debit B's stake, and move A's lock state to the destination. That detaches lock/conviction state from the stake it is supposed to constrain. Bind the bucket calculation and lock movement to the selected hotkey, and reject locked=true when the selected hotkey is not the active lock hotkey.

origin_coldkey,
origin_netuid,
locked,
))
Comment on lines +366 to +370

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Lock-aware cap is not bound to the selected hotkey

transfer_stake_lock_aware first caps the request against lock_aware_transferable_alpha(origin_coldkey, origin_netuid, locked), but this helper has no origin_hotkey parameter. The later debit/credit still happens on the caller-selected origin_hotkey, while transfer_lock reads and moves the coldkey's existing lock hotkey via read_conviction_model. A coldkey with a lock on hotkey A and stake on hotkey B can therefore call the locked path for B: the cap is satisfied by A's lock, the stake moved is B's stake, and the lock/conviction state moved is A's. Bind the selected bucket to the selected hotkey, or reject calls where the requested hotkey is not the coldkey's current lock hotkey for locked transfers.

})
.unwrap_or(alpha_amount);
Comment on lines +364 to +372

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Capped lock-aware amount can mutate stake before failing minimum check

For same-subnet lock-aware transfers, this cap can reduce a caller-supplied alpha_amount to a dust-sized selected bucket after any transaction-extension or input-level minimum checks have seen the original amount. The capped value then reaches transfer_stake_within_subnet, which calls transfer_lock and decreases/increases stake before computing tao_equivalent and returning AmountTooLow for sub-minimum transfers. A caller can submit a large alpha_amount, have it capped to a below-minimum locked/unlocked remainder, and still move stake/lock state through a dispatch that reports failure. Validate the capped move_amount against DefaultMinStake before any lock/stake mutation, or move the same-netuid minimum check ahead of transfer_lock and the stake balance updates.


// Calculate the maximum amount that can be executed
let max_amount = if origin_netuid != destination_netuid {
Expand Down Expand Up @@ -393,6 +452,7 @@ impl<T: Config> Pallet<T> {
destination_hotkey,
origin_netuid,
move_amount,
lock_aware_transfer,
)
}
}
Expand Down
39 changes: 23 additions & 16 deletions pallets/subtensor/src/staking/stake_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -961,9 +961,31 @@ impl<T: Config> Pallet<T> {
destination_hotkey: &T::AccountId,
netuid: NetUid,
alpha: AlphaBalance,
lock_aware_transfer: Option<bool>,
) -> Result<TaoBalance, DispatchError> {
// Calculate TAO equivalent based on current price (it is accurate because
// there's no slippage in this move) and validate it before mutating lock
// or stake storage.
let current_price =
<T as pallet::Config>::SwapInterface::current_alpha_price(netuid.into());
let tao_equivalent: TaoBalance = current_price
.saturating_mul(U64F64::saturating_from_num(alpha))
.saturating_to_num::<u64>()
.into();

ensure!(
tao_equivalent >= DefaultMinStake::<T>::get(),
Error::<T>::AmountTooLow
);

// Transfer lock (may fail if destination coldkey has a conflicting lock)
Self::transfer_lock(origin_coldkey, destination_coldkey, netuid, alpha)?;
Self::transfer_lock(
origin_coldkey,
destination_coldkey,
netuid,
alpha,
lock_aware_transfer,
)?;

// Decrease alpha on origin keys
Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(
Expand Down Expand Up @@ -995,21 +1017,6 @@ impl<T: Config> Pallet<T> {
);
}

// Calculate TAO equivalent based on current price (it is accurate because
// there's no slippage in this move)
let current_price =
<T as pallet::Config>::SwapInterface::current_alpha_price(netuid.into());
let tao_equivalent: TaoBalance = current_price
.saturating_mul(U64F64::saturating_from_num(alpha))
.saturating_to_num::<u64>()
.into();

// Ensure tao_equivalent is above DefaultMinStake
ensure!(
tao_equivalent >= DefaultMinStake::<T>::get(),
Error::<T>::AmountTooLow
);

// Step 3: Update StakingHotkeys if the hotkey's total alpha, across all subnets, is zero
// TODO: fix.
// if Self::get_stake(hotkey, coldkey) == 0 {
Expand Down
2 changes: 2 additions & 0 deletions pallets/subtensor/src/subnets/leasing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ impl<T: Config> Pallet<T> {
&lease.hotkey,
lease.netuid,
alpha_for_contributor.into(),
None,
)?;
alpha_distributed = alpha_distributed.saturating_add(alpha_for_contributor.into());

Expand All @@ -332,6 +333,7 @@ impl<T: Config> Pallet<T> {
&lease.hotkey,
lease.netuid,
beneficiary_cut_alpha.into(),
None,
)?;
Self::deposit_event(Event::SubnetLeaseDividendsDistributed {
lease_id,
Expand Down
Loading
Loading