|
| 1 | +""" |
| 2 | +Unit tests for display_stake_movement_cross_subnets in stake/move.py. |
| 3 | +
|
| 4 | +Covers: |
| 5 | + - Cross-subnet received amount uses sim_swap.alpha_amount (not linear math) |
| 6 | + - Cross-subnet with proxy does not deduct extrinsic fee from received |
| 7 | + - Cross-subnet without proxy deducts extrinsic fee from received |
| 8 | + - Same-subnet still uses existing linear pricing |
| 9 | + - Cross-subnet raises ValueError when received amount is negative |
| 10 | +""" |
| 11 | + |
| 12 | +from types import SimpleNamespace |
| 13 | +from unittest.mock import MagicMock, patch |
| 14 | + |
| 15 | +import pytest |
| 16 | + |
| 17 | +from bittensor_cli.src.bittensor.balances import Balance |
| 18 | +from bittensor_cli.src.commands.stake.move import ( |
| 19 | + display_stake_movement_cross_subnets, |
| 20 | + MovementPricing, |
| 21 | +) |
| 22 | + |
| 23 | +MODULE = "bittensor_cli.src.commands.stake.move" |
| 24 | + |
| 25 | + |
| 26 | +def _make_subnet(netuid: int, price_tao: float): |
| 27 | + """Build a mock DynamicInfo with working alpha_to_tao / tao_to_alpha.""" |
| 28 | + subnet = MagicMock() |
| 29 | + subnet.price = Balance.from_tao(price_tao) |
| 30 | + subnet.is_dynamic = netuid != 0 |
| 31 | + subnet.netuid = netuid |
| 32 | + |
| 33 | + def alpha_to_tao(alpha: Balance) -> Balance: |
| 34 | + return Balance.from_tao(alpha.tao * price_tao) |
| 35 | + |
| 36 | + def tao_to_alpha(tao: Balance) -> Balance: |
| 37 | + if price_tao == 0: |
| 38 | + return Balance.from_tao(0) |
| 39 | + return Balance.from_tao(tao.tao / price_tao).set_unit(netuid) |
| 40 | + |
| 41 | + subnet.alpha_to_tao = alpha_to_tao |
| 42 | + subnet.tao_to_alpha = tao_to_alpha |
| 43 | + return subnet |
| 44 | + |
| 45 | + |
| 46 | +def _make_sim_swap( |
| 47 | + alpha_amount_tao: float, dest_netuid: int, alpha_fee_tao: float = 1.0 |
| 48 | +): |
| 49 | + """Build a SimpleNamespace matching SimSwapResult shape.""" |
| 50 | + return SimpleNamespace( |
| 51 | + alpha_amount=Balance.from_tao(alpha_amount_tao).set_unit(dest_netuid), |
| 52 | + tao_amount=Balance.from_tao(alpha_amount_tao), |
| 53 | + alpha_fee=Balance.from_tao(alpha_fee_tao).set_unit(dest_netuid), |
| 54 | + tao_fee=Balance.from_tao(alpha_fee_tao), |
| 55 | + ) |
| 56 | + |
| 57 | + |
| 58 | +# --------------------------------------------------------------------------- |
| 59 | +# Cross-subnet tests |
| 60 | +# --------------------------------------------------------------------------- |
| 61 | + |
| 62 | + |
| 63 | +class TestCrossSubnetDisplay: |
| 64 | + @pytest.mark.asyncio |
| 65 | + async def test_received_amount_uses_sim_swap_not_linear_math(self): |
| 66 | + """The cross-subnet received amount must come from sim_swap.alpha_amount, |
| 67 | + not from linear alpha_to_tao/tao_to_alpha calculations.""" |
| 68 | + origin_netuid, dest_netuid = 1, 2 |
| 69 | + # Price deliberately set so linear math would give a very different result |
| 70 | + origin_subnet = _make_subnet(origin_netuid, price_tao=2.0) |
| 71 | + dest_subnet = _make_subnet(dest_netuid, price_tao=0.5) |
| 72 | + pricing = MovementPricing( |
| 73 | + origin_subnet=origin_subnet, |
| 74 | + destination_subnet=dest_subnet, |
| 75 | + rate=4.0, |
| 76 | + rate_with_tolerance=None, |
| 77 | + ) |
| 78 | + amount = Balance.from_tao(10.0).set_unit(origin_netuid) |
| 79 | + stake_fee = Balance.from_tao(0.5).set_unit(origin_netuid) |
| 80 | + extrinsic_fee = Balance.from_tao(0.0) |
| 81 | + # sim_swap says user receives 35 alpha on dest — linear math would give ~38 |
| 82 | + sim_swap = _make_sim_swap(alpha_amount_tao=35.0, dest_netuid=dest_netuid) |
| 83 | + |
| 84 | + with patch(f"{MODULE}.console"): |
| 85 | + received, _ = await display_stake_movement_cross_subnets( |
| 86 | + subtensor=MagicMock(network="test"), |
| 87 | + origin_netuid=origin_netuid, |
| 88 | + destination_netuid=dest_netuid, |
| 89 | + origin_hotkey="5C" + "a" * 46, |
| 90 | + destination_hotkey="5C" + "b" * 46, |
| 91 | + amount_to_move=amount, |
| 92 | + pricing=pricing, |
| 93 | + sim_swap=sim_swap, |
| 94 | + stake_fee=stake_fee, |
| 95 | + extrinsic_fee=extrinsic_fee, |
| 96 | + proxy="5C" + "p" * 46, # proxy → no extrinsic_fee deduction |
| 97 | + ) |
| 98 | + |
| 99 | + assert received.tao == pytest.approx(35.0, abs=1e-6) |
| 100 | + |
| 101 | + @pytest.mark.asyncio |
| 102 | + async def test_proxy_does_not_deduct_extrinsic_fee(self): |
| 103 | + """With a proxy, the extrinsic fee should not reduce the received amount.""" |
| 104 | + origin_netuid, dest_netuid = 1, 2 |
| 105 | + dest_subnet = _make_subnet(dest_netuid, price_tao=1.0) |
| 106 | + pricing = MovementPricing( |
| 107 | + origin_subnet=_make_subnet(origin_netuid, price_tao=1.0), |
| 108 | + destination_subnet=dest_subnet, |
| 109 | + rate=1.0, |
| 110 | + rate_with_tolerance=None, |
| 111 | + ) |
| 112 | + sim_swap = _make_sim_swap(alpha_amount_tao=50.0, dest_netuid=dest_netuid) |
| 113 | + extrinsic_fee = Balance.from_tao(0.5) |
| 114 | + |
| 115 | + with patch(f"{MODULE}.console"): |
| 116 | + received, _ = await display_stake_movement_cross_subnets( |
| 117 | + subtensor=MagicMock(network="test"), |
| 118 | + origin_netuid=origin_netuid, |
| 119 | + destination_netuid=dest_netuid, |
| 120 | + origin_hotkey="5C" + "a" * 46, |
| 121 | + destination_hotkey="5C" + "b" * 46, |
| 122 | + amount_to_move=Balance.from_tao(50).set_unit(origin_netuid), |
| 123 | + pricing=pricing, |
| 124 | + sim_swap=sim_swap, |
| 125 | + stake_fee=Balance.from_tao(0), |
| 126 | + extrinsic_fee=extrinsic_fee, |
| 127 | + proxy="5C" + "p" * 46, |
| 128 | + ) |
| 129 | + |
| 130 | + # Full sim_swap amount — extrinsic fee NOT deducted |
| 131 | + assert received.tao == pytest.approx(50.0, abs=1e-6) |
| 132 | + |
| 133 | + @pytest.mark.asyncio |
| 134 | + async def test_no_proxy_deducts_extrinsic_fee(self): |
| 135 | + """Without a proxy, the extrinsic fee should reduce the received amount.""" |
| 136 | + origin_netuid, dest_netuid = 1, 2 |
| 137 | + dest_subnet = _make_subnet(dest_netuid, price_tao=1.0) |
| 138 | + pricing = MovementPricing( |
| 139 | + origin_subnet=_make_subnet(origin_netuid, price_tao=1.0), |
| 140 | + destination_subnet=dest_subnet, |
| 141 | + rate=1.0, |
| 142 | + rate_with_tolerance=None, |
| 143 | + ) |
| 144 | + sim_swap = _make_sim_swap(alpha_amount_tao=50.0, dest_netuid=dest_netuid) |
| 145 | + extrinsic_fee = Balance.from_tao(0.5) |
| 146 | + |
| 147 | + with patch(f"{MODULE}.console"): |
| 148 | + received, _ = await display_stake_movement_cross_subnets( |
| 149 | + subtensor=MagicMock(network="test"), |
| 150 | + origin_netuid=origin_netuid, |
| 151 | + destination_netuid=dest_netuid, |
| 152 | + origin_hotkey="5C" + "a" * 46, |
| 153 | + destination_hotkey="5C" + "b" * 46, |
| 154 | + amount_to_move=Balance.from_tao(50).set_unit(origin_netuid), |
| 155 | + pricing=pricing, |
| 156 | + sim_swap=sim_swap, |
| 157 | + stake_fee=Balance.from_tao(0), |
| 158 | + extrinsic_fee=extrinsic_fee, |
| 159 | + ) |
| 160 | + |
| 161 | + # Extrinsic fee converted to dest alpha (price=1.0 so 0.5 TAO → 0.5 alpha) |
| 162 | + assert received.tao == pytest.approx(49.5, abs=1e-6) |
| 163 | + |
| 164 | + @pytest.mark.asyncio |
| 165 | + async def test_negative_received_raises_value_error(self): |
| 166 | + """When fees exceed the swap result, ValueError must be raised.""" |
| 167 | + origin_netuid, dest_netuid = 1, 2 |
| 168 | + dest_subnet = _make_subnet(dest_netuid, price_tao=1.0) |
| 169 | + pricing = MovementPricing( |
| 170 | + origin_subnet=_make_subnet(origin_netuid, price_tao=1.0), |
| 171 | + destination_subnet=dest_subnet, |
| 172 | + rate=1.0, |
| 173 | + rate_with_tolerance=None, |
| 174 | + ) |
| 175 | + # Tiny swap result, large extrinsic fee → negative received |
| 176 | + sim_swap = _make_sim_swap(alpha_amount_tao=0.001, dest_netuid=dest_netuid) |
| 177 | + extrinsic_fee = Balance.from_tao(1.0) |
| 178 | + |
| 179 | + with patch(f"{MODULE}.console"), pytest.raises(ValueError): |
| 180 | + await display_stake_movement_cross_subnets( |
| 181 | + subtensor=MagicMock(network="test"), |
| 182 | + origin_netuid=origin_netuid, |
| 183 | + destination_netuid=dest_netuid, |
| 184 | + origin_hotkey="5C" + "a" * 46, |
| 185 | + destination_hotkey="5C" + "b" * 46, |
| 186 | + amount_to_move=Balance.from_tao(1).set_unit(origin_netuid), |
| 187 | + pricing=pricing, |
| 188 | + sim_swap=sim_swap, |
| 189 | + stake_fee=Balance.from_tao(0), |
| 190 | + extrinsic_fee=extrinsic_fee, |
| 191 | + ) |
| 192 | + |
| 193 | + |
| 194 | +# --------------------------------------------------------------------------- |
| 195 | +# Same-subnet tests (behaviour must be unchanged) |
| 196 | +# --------------------------------------------------------------------------- |
| 197 | + |
| 198 | + |
| 199 | +class TestSameSubnetDisplay: |
| 200 | + @pytest.mark.asyncio |
| 201 | + async def test_same_subnet_uses_linear_pricing(self): |
| 202 | + """Same-subnet moves use linear alpha_to_tao/tao_to_alpha, not sim_swap.""" |
| 203 | + netuid = 3 |
| 204 | + price = 2.0 |
| 205 | + subnet = _make_subnet(netuid, price_tao=price) |
| 206 | + pricing = MovementPricing( |
| 207 | + origin_subnet=subnet, |
| 208 | + destination_subnet=subnet, |
| 209 | + rate=1.0, |
| 210 | + rate_with_tolerance=None, |
| 211 | + ) |
| 212 | + amount = Balance.from_tao(10.0).set_unit(netuid) |
| 213 | + stake_fee = Balance.from_tao(0.5).set_unit(netuid) |
| 214 | + extrinsic_fee = Balance.from_tao(0.0) |
| 215 | + # sim_swap with a wildly different alpha_amount to prove it's not used |
| 216 | + sim_swap = _make_sim_swap(alpha_amount_tao=999.0, dest_netuid=netuid) |
| 217 | + |
| 218 | + with patch(f"{MODULE}.console"): |
| 219 | + received, _ = await display_stake_movement_cross_subnets( |
| 220 | + subtensor=MagicMock(network="test"), |
| 221 | + origin_netuid=netuid, |
| 222 | + destination_netuid=netuid, |
| 223 | + origin_hotkey="5C" + "a" * 46, |
| 224 | + destination_hotkey="5C" + "b" * 46, |
| 225 | + amount_to_move=amount, |
| 226 | + pricing=pricing, |
| 227 | + sim_swap=sim_swap, |
| 228 | + stake_fee=stake_fee, |
| 229 | + extrinsic_fee=extrinsic_fee, |
| 230 | + proxy="5C" + "p" * 46, |
| 231 | + ) |
| 232 | + |
| 233 | + # Linear: (10 - 0.5) * 2.0 / 2.0 = 9.5 (proxy → no extrinsic fee deduction) |
| 234 | + assert received.tao == pytest.approx(9.5, abs=1e-6) |
0 commit comments