diff --git a/bittensor/utils/balance.py b/bittensor/utils/balance.py index 393481b42f..9d6e5dc5a5 100644 --- a/bittensor/utils/balance.py +++ b/bittensor/utils/balance.py @@ -1,3 +1,4 @@ +from decimal import Decimal from typing import Optional, Union from scalecodec.utils.math import ( @@ -9,6 +10,17 @@ from bittensor.core.errors import BalanceTypeError, BalanceUnitMismatchError +def _tao_to_rao(amount: Union[int, float]) -> int: + """Convert a tao amount to integer rao without floating-point truncation. + + ``int(float_amount * 1e9)`` silently loses rao because binary floats cannot + represent most decimal fractions (e.g. ``1.001`` tao -> 1000999999 rao instead + of 1001000000). Routing through :class:`decimal.Decimal` from the value's + decimal string makes the conversion exact, so no rao is silently dropped. + """ + return int(Decimal(str(amount)) * 1_000_000_000) + + def _check_currencies(self, other): """Checks that Balance objects have the same netuids to perform arithmetic operations. @@ -97,7 +109,7 @@ def __init__(self, balance: Union[int, float]): self.rao = balance elif isinstance(balance, float): # Assume tao value for the float - self.rao = int(balance * pow(10, 9)) + self.rao = _tao_to_rao(balance) else: raise TypeError( f"Balance must be an int (rao) or a float (tao), not `{type(balance)}`." @@ -314,7 +326,7 @@ def from_float(amount: float, netuid: int = 0) -> "Balance": Returns: A Balance object representing the given amount. """ - rao_ = int(amount * pow(10, 9)) + rao_ = _tao_to_rao(amount) return Balance(rao_).set_unit(netuid) @staticmethod @@ -329,7 +341,7 @@ def from_tao(amount: float, netuid: int = 0) -> "Balance": Returns: A Balance object representing the given amount. """ - rao_ = int(amount * pow(10, 9)) + rao_ = _tao_to_rao(amount) return Balance(rao_).set_unit(netuid) @staticmethod diff --git a/mypy.ini b/mypy.ini index d38bdc7172..054851282e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,13 @@ [mypy] ignore_missing_imports = True ignore_errors = True +follow_imports_for_stubs = True + +[mypy-numpy.*] +follow_imports = skip + +[mypy-numpy] +follow_imports = skip [mypy-*.axon.*] ignore_errors = False diff --git a/tests/unit_tests/utils/test_balance.py b/tests/unit_tests/utils/test_balance.py index 4a5663488f..4696fceb4f 100644 --- a/tests/unit_tests/utils/test_balance.py +++ b/tests/unit_tests/utils/test_balance.py @@ -224,11 +224,7 @@ def test_balance_mul_other_not_balance( """ balance_ = Balance(balance) balance2_ = balance2 - rao_: int - if isinstance(balance, int): - rao_ = balance - elif isinstance(balance, float): - rao_ = int(balance * pow(10, 9)) + rao_ = balance_.rao prod_ = balance_ * balance2_ assert isinstance(prod_, Balance) @@ -248,11 +244,7 @@ def test_balance_rmul_other_not_balance( """ balance_ = Balance(balance) balance2_ = balance2 - rao_: int - if isinstance(balance, int): - rao_ = balance - elif isinstance(balance, float): - rao_ = int(balance * pow(10, 9)) + rao_ = balance_.rao prod_ = balance2_ * balance_ # This is an rmul assert isinstance(prod_, Balance) @@ -304,12 +296,8 @@ def test_balance_truediv_other_not_balance( """ balance_ = Balance(balance) balance2_ = balance2 - rao_: int rao2_: int - if isinstance(balance, int): - rao_ = balance - elif isinstance(balance, float): - rao_ = int(balance * pow(10, 9)) + rao_ = balance_.rao # assume balance2 is a rao value rao2_ = balance2 @@ -388,12 +376,8 @@ def test_balance_floordiv_other_not_balance( """ balance_ = Balance(balance) balance2_ = balance2 - rao_: int rao2_: int - if isinstance(balance, int): - rao_ = balance - elif isinstance(balance, float): - rao_ = int(balance * pow(10, 9)) + rao_ = balance_.rao # assume balance2 is a rao value rao2_ = balance2 @@ -520,6 +504,34 @@ def test_from_rao(): assert Balance.from_tao(1) == Balance(1000000000) +@pytest.mark.parametrize( + "tao,expected_rao", + [ + (1.001, 1001000000), + (1.003, 1003000000), + (0.001, 1000000), + (123.456, 123456000000), + (1.0, 1000000000), + ], + ids=[ + "truncating-float-1.001", + "truncating-float-1.003", + "milli-tao", + "three-decimals", + "whole", + ], +) +def test_from_tao_no_float_truncation(tao, expected_rao): + """from_tao must convert tao to rao exactly; binary float math used to drop rao + (e.g. from_tao(1.001) returned 1000999999 rao, losing 1 rao).""" + assert Balance.from_tao(tao).rao == expected_rao + + +def test_balance_init_float_no_float_truncation(): + """Balance(float) takes tao and must not drop rao to float truncation either.""" + assert Balance(1.001).rao == 1001000000 + + @pytest.mark.parametrize( "first, second", [