Skip to content

Chain extension AddStakeRecycleV1 / AddStakeBurnV1 are non-atomic #2666

@Maksandre

Description

@Maksandre

Describe the bug

The composite chain-extension entry points AddStakeRecycleV1 / AddStakeBurnV1 (chain-extensions/src/lib.rs:792-853) invoke pallet-level pub fn helpers that are not #[pallet::call] dispatchables - so FRAME's automatic with_storage_layer wrap does not apply. If the second leg (do_recycle_alpha or do_burn_alpha) returns Err after the first leg (do_add_stake) succeeded, the first leg's storage writes persist. The doc-comment on the helper still claims atomicity ("without leaving residual stake if the second leg fails").

This is a silent regression: commit 46989aaba (2026-04-17) originally wrapped both helpers in transactional::with_transaction to deliver the documented atomicity; commit d0702db5a (2026-05-04, "add migration part") removed the wrapper without mention in the commit message.

To Reproduce

Simplest trigger: netuid = NetUid::ROOT. do_add_stake succeeds on the root subnet, then do_recycle_alpha / do_burn_alpha returns Error::CannotBurnOrRecycleOnRootSubnet (pallets/subtensor/src/staking/recycle_alpha.rs:28-31 and :89-92).

  1. Deploy any ink! contract whose #[ink(message)] calling the chain extension returns a non-Result type (so ink!'s automatic revert-on-Err dispatch wrapper does not trip). Raw WASM / PolkaVM contracts naturally expose the bug.
  2. From the contract, invoke AddStakeRecycleV1(hotkey, netuid = NetUid::ROOT, tao_amount = 100 TAO) (or the burn variant).
  3. The chain extension returns Output::CannotBurnOrRecycleOnRootSubnet = 21.
  4. Inspect the contract's TAO balance, AlphaV2((hotkey, contract, ROOT)), SubnetTAO[ROOT], SubnetAlphaOut[ROOT], TotalStake.

Observed: contract TAO debited by 100 TAO; root alpha credited 100e9 to (hotkey, contract, ROOT); SubnetTAO[ROOT], SubnetAlphaOut[ROOT], TotalStake all bumped - despite the chain extension returning a failure code that the composite was supposed to roll back.

Other plausible failure modes for the second leg:

  • A pre-existing Lock row on (contract-account, netuid, hotkey) reduces available_stake = total - locked - unlocked below the freshly-minted alpha, so ensure_available_stake (recycle_alpha.rs:54) returns StakeUnavailable.
  • An intermediate state-change inside do_add_stake -> stake_into_subnet can also commit partially: transfer_tao_to_subnet moves real pallet-balances TAO before the internal swap_tao_for_alpha, leaving the TAO stranded if the swap fails.

Expected behavior

AddStakeRecycleV1 / AddStakeBurnV1 must be atomic - either both legs succeed or no storage writes persist. The doc-comment on do_add_stake_recycle already states this contract: "so that contracts can compose the two operations without leaving residual stake if the second leg fails."

Screenshots

No response

Environment

opentensor/subtensor testnet @ e6a5f56cefeb96b9c63ea3ce6553a8e1066aaeb9

Additional context

Affected code

chain-extensions/src/lib.rs:792-853:

FunctionId::AddStakeRecycleV1 => {
    // ...
    Pallet::<T>::do_add_stake_recycle(origin, hotkey, netuid, amount) // <-- no with_storage_layer
}

FunctionId::AddStakeBurnV1 => {
    // ...
    Pallet::<T>::do_add_stake_burn_permissionless(origin, hotkey, netuid, amount) // <-- no with_storage_layer
}

pallets/subtensor/src/staking/recycle_alpha.rs:157-178:

pub fn do_add_stake_recycle(origin, hotkey, netuid, amount) -> Result<AlphaBalance, _> {
    let alpha = Self::do_add_stake(origin.clone(), hotkey.clone(), netuid, amount)?;
    Self::do_recycle_alpha(origin, hotkey, alpha, netuid)
}

pub fn do_add_stake_burn_permissionless(origin, hotkey, netuid, amount) -> Result<AlphaBalance, _> {
    let alpha = Self::do_add_stake(origin.clone(), hotkey.clone(), netuid, amount)?;
    Self::do_burn_alpha(origin, hotkey, alpha, netuid)
}

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions