-
Notifications
You must be signed in to change notification settings - Fork 316
Chain extension AddStakeRecycleV1 / AddStakeBurnV1 are non-atomic #2666
Description
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).
- Deploy any ink! contract whose
#[ink(message)]calling the chain extension returns a non-Resulttype (so ink!'s automatic revert-on-Errdispatch wrapper does not trip). Raw WASM / PolkaVM contracts naturally expose the bug. - From the contract, invoke
AddStakeRecycleV1(hotkey, netuid = NetUid::ROOT, tao_amount = 100 TAO)(or the burn variant). - The chain extension returns
Output::CannotBurnOrRecycleOnRootSubnet = 21. - 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
Lockrow on(contract-account, netuid, hotkey)reducesavailable_stake = total - locked - unlockedbelow the freshly-mintedalpha, soensure_available_stake(recycle_alpha.rs:54) returnsStakeUnavailable. - An intermediate state-change inside
do_add_stake -> stake_into_subnetcan also commit partially:transfer_tao_to_subnetmoves realpallet-balancesTAO before the internalswap_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) }