Skip to content

Commit d0fe30e

Browse files
committed
Fix cross-subnet received amount display using sim_swap and add missing proxy check
1 parent 0bc8015 commit d0fe30e

File tree

2 files changed

+246
-7
lines changed

2 files changed

+246
-7
lines changed

bittensor_cli/src/commands/stake/move.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
if TYPE_CHECKING:
3030
from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface
31-
from bittensor_cli.src.bittensor.chain_data import DynamicInfo
31+
from bittensor_cli.src.bittensor.chain_data import DynamicInfo, SimSwapResult
3232

3333
MIN_STAKE_FEE = Balance.from_rao(50_000)
3434

@@ -99,6 +99,7 @@ async def display_stake_movement_cross_subnets(
9999
destination_hotkey: str,
100100
amount_to_move: Balance,
101101
pricing: MovementPricing,
102+
sim_swap: "SimSwapResult",
102103
stake_fee: Balance,
103104
extrinsic_fee: Balance,
104105
safe_staking: bool = False,
@@ -116,6 +117,7 @@ async def display_stake_movement_cross_subnets(
116117
destination_hotkey: The destination hotkey SS58 address.
117118
amount_to_move: The amount of stake to move/swap.
118119
pricing: Pricing information including rates and limits.
120+
sim_swap: SimSwapResult from the runtime API with accurate swap amounts.
119121
stake_fee: The fee for the stake transaction.
120122
extrinsic_fee: The fee for the extrinsic execution.
121123
safe_staking: Whether to enable safe staking.
@@ -147,12 +149,12 @@ async def display_stake_movement_cross_subnets(
147149
+ f"({Balance.get_unit(0)}/{Balance.get_unit(origin_netuid)})"
148150
)
149151
else:
150-
dynamic_origin = pricing.origin_subnet
151-
dynamic_destination = pricing.destination_subnet
152-
received_amount_tao = (
153-
dynamic_origin.alpha_to_tao(amount_to_move - stake_fee) - extrinsic_fee
154-
)
155-
received_amount = dynamic_destination.tao_to_alpha(received_amount_tao)
152+
received_amount = sim_swap.alpha_amount
153+
if not proxy:
154+
extrinsic_fee_as_alpha = pricing.destination_subnet.tao_to_alpha(
155+
extrinsic_fee
156+
)
157+
received_amount = received_amount - extrinsic_fee_as_alpha
156158
received_amount.set_unit(destination_netuid)
157159

158160
if received_amount < Balance.from_tao(0).set_unit(destination_netuid):
@@ -670,6 +672,7 @@ async def move_stake(
670672
destination_hotkey=destination_hotkey,
671673
amount_to_move=amount_to_move_as_balance,
672674
pricing=pricing,
675+
sim_swap=sim_swap,
673676
stake_fee=sim_swap.alpha_fee
674677
if origin_netuid != 0
675678
else sim_swap.tao_fee,
@@ -889,6 +892,7 @@ async def transfer_stake(
889892
destination_hotkey=origin_hotkey,
890893
amount_to_move=amount_to_transfer,
891894
pricing=pricing,
895+
sim_swap=sim_swap,
892896
stake_fee=sim_swap.alpha_fee
893897
if origin_netuid != 0
894898
else sim_swap.tao_fee,
@@ -1116,6 +1120,7 @@ async def swap_stake(
11161120
destination_hotkey=hotkey_ss58,
11171121
amount_to_move=amount_to_swap,
11181122
pricing=pricing,
1123+
sim_swap=sim_swap,
11191124
stake_fee=sim_swap.alpha_fee
11201125
if origin_netuid != 0
11211126
else sim_swap.tao_fee,
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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

Comments
 (0)