From 48d2aad20311013c3f092ebb879cf2486608936f Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 30 Jul 2026 11:24:51 -0500 Subject: [PATCH] fix(facade): exact credits/microcredits conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both directions went through binary floating point and lost value. `credits_to_microcredits` multiplied by 1_000_000 as a float and then truncated toward zero with int(), so ordinary amounts silently underpaid: 1.005 credits became 1_004_999 microcredits, not 1_005_000. Sub-microcredit input was dropped with no error at all (0.9999999 -> 999_999). `microcredits_to_credits` returned a float. Microcredits are a u64, and past 2**53 a float cannot hold the integer — u64 max round-tripped off by one, and 2884 of the first 200_000 microcredit values failed `micro -> credits -> micro` identity. Both now compute in Decimal. Float input is routed through str(), which recovers the shortest representation that round-trips — i.e. the literal the caller wrote — which is what rescues 1.005; str and Decimal input are exact already. Sub-microcredit precision now raises ValueError naming the value, with allow_rounding=True to opt back into truncation, so lost value is an error rather than a silent underpayment. `from_microcredits` returns Decimal instead of float. It still compares equal to the obvious float, so existing assertions hold unchanged, but mixing it into float arithmetic now raises — convert with float() deliberately if you want that, accepting the loss. Scope is contained: these are user-facing convenience helpers only. No internal fee or amount path consumes them (the SDK is integer microcredits end to end), and shield-swap does not use them. Verified: 896 passed (sdk, -m "not slow"; +6 new), pyright strict 0 errors, shield-swap 174 passed and unaffected. --- sdk/python/aleo/_facade_common.py | 91 ++++++++++++++++++++++---- sdk/python/aleo/facade/async_client.py | 35 +++++++--- sdk/python/aleo/facade/client.py | 42 +++++++++--- sdk/python/tests/test_facade_client.py | 50 ++++++++++++++ 4 files changed, 191 insertions(+), 27 deletions(-) diff --git a/sdk/python/aleo/_facade_common.py b/sdk/python/aleo/_facade_common.py index 164ffc9f..76a9c8be 100644 --- a/sdk/python/aleo/_facade_common.py +++ b/sdk/python/aleo/_facade_common.py @@ -5,10 +5,15 @@ """ from __future__ import annotations -from typing import Any, Protocol, runtime_checkable +from decimal import Decimal, ROUND_DOWN +from typing import Any, Protocol, Union, runtime_checkable _MICROCREDITS_PER_CREDIT: int = 1_000_000 +#: What a credits amount may be written as. ``str`` and ``Decimal`` are exact; +#: ``float`` is not (see :func:`credits_to_microcredits`). +CreditsAmount = Union[str, int, Decimal, float] + @runtime_checkable class RecordProvider(Protocol): @@ -46,8 +51,44 @@ def find(self, **filters: Any) -> list[Any]: ... -def credits_to_microcredits(credits: float | int) -> int: - """Convert a credits amount (float or int) to integer microcredits. +def _to_decimal(credits: CreditsAmount) -> Decimal: + """Interpret a credits amount exactly. + + A ``float`` has already lost the value the caller wrote — ``1.005`` is really + ``1.00499999999999989...`` — so it is routed through ``str()``, which yields + the shortest representation that round-trips, i.e. the literal as typed. + ``str``, ``int``, and ``Decimal`` are exact already. + """ + if isinstance(credits, Decimal): + return credits + if isinstance(credits, int): + return Decimal(credits) + return Decimal(str(credits)) + + +def credits_to_microcredits( + credits: CreditsAmount, *, allow_rounding: bool = False +) -> int: + """Convert a credits amount to integer microcredits, exactly. + + Computed in decimal, never binary floating point: ``1.005`` credits is + 1_005_000 microcredits, where a float multiply would truncate to 1_004_999 + and silently lose value. Pass *credits* as ``str`` or ``Decimal`` for full + exactness; a ``float`` is interpreted as the decimal literal it prints as. + + Args: + credits: Credits amount — ``"1.005"``, ``Decimal("1.005")``, ``1``, or + ``1.005``. + allow_rounding: Permit input finer than one microcredit, truncating + toward zero. Off by default so lost precision is an error rather + than a silent underpayment. + + Returns: + The amount in microcredits. + + Raises: + ValueError: If *credits* carries sub-microcredit precision and + *allow_rounding* is False, or is not a usable number. Examples -------- @@ -55,18 +96,46 @@ def credits_to_microcredits(credits: float | int) -> int: 1000000 >>> credits_to_microcredits(1.5) 1500000 + >>> credits_to_microcredits("1.005") + 1005000 """ - return int(credits * _MICROCREDITS_PER_CREDIT) - - -def microcredits_to_credits(microcredits: int) -> float: - """Convert an integer microcredits amount to a credits float. + try: + scaled = _to_decimal(credits) * _MICROCREDITS_PER_CREDIT + except (ArithmeticError, ValueError, TypeError) as exc: + raise ValueError(f"{credits!r} is not a valid credits amount") from exc + if not scaled.is_finite(): + raise ValueError(f"{credits!r} is not a finite credits amount") + rounded = scaled.to_integral_value(rounding=ROUND_DOWN) + if scaled != rounded and not allow_rounding: + raise ValueError( + f"{credits!r} credits is {scaled} microcredits — finer than the " + "chain's smallest unit. Round it yourself, or pass " + "allow_rounding=True to truncate toward zero." + ) + return int(rounded) + + +def microcredits_to_credits(microcredits: int) -> Decimal: + """Convert integer microcredits to credits, exactly. + + Returns a :class:`~decimal.Decimal` rather than a float: microcredits are a + ``u64``, and past 2**53 a float cannot even hold the integer, so a float + round-trip is not the identity. Compares equal to the obvious float + (``microcredits_to_credits(1_500_000) == 1.5``), but mixing it into float + arithmetic raises — convert deliberately with ``float(...)`` if you want + that, accepting the precision loss. + + Args: + microcredits: Amount in microcredits. + + Returns: + The amount in credits, exact at any ``u64`` magnitude. Examples -------- >>> microcredits_to_credits(1_000_000) - 1.0 + Decimal('1.000000') >>> microcredits_to_credits(1_500_000) - 1.5 + Decimal('1.500000') """ - return microcredits / _MICROCREDITS_PER_CREDIT + return Decimal(int(microcredits)).scaleb(-6) diff --git a/sdk/python/aleo/facade/async_client.py b/sdk/python/aleo/facade/async_client.py index 8c430dda..aa90099c 100644 --- a/sdk/python/aleo/facade/async_client.py +++ b/sdk/python/aleo/facade/async_client.py @@ -32,10 +32,15 @@ import asyncio from contextlib import asynccontextmanager +from decimal import Decimal from typing import Any, AsyncGenerator from .._client_common import AleoNetworkError -from .._facade_common import credits_to_microcredits, microcredits_to_credits +from .._facade_common import ( + CreditsAmount, + credits_to_microcredits, + microcredits_to_credits, +) from .._scanner_common import OwnedRecord, RecordNotFoundError from .errors import ( ExecutionError, @@ -1238,20 +1243,34 @@ async def get_balance(self, address: str) -> int: # ── Unit conversions (sync) ───────────────────────────────────────────── - def to_microcredits(self, credits: float | int) -> int: - """Convert a credits amount to integer microcredits. + def to_microcredits( + self, credits: CreditsAmount, *, allow_rounding: bool = False + ) -> int: + """Convert a credits amount to integer microcredits, exactly. - ``1 credit == 1_000_000 microcredits`` + Local and synchronous. See :meth:`Aleo.to_microcredits` — computed in + decimal, so ``1.005`` gives 1_005_000 rather than 1_004_999. Parameters ---------- credits: - Credits amount as a float or integer (e.g. ``1.5``). + Credits amount; ``str`` and ``Decimal`` are exact. + allow_rounding: + Permit input finer than one microcredit, truncating toward zero. + + Raises + ------ + ValueError + If *credits* is finer than a microcredit and *allow_rounding* is + False, or is not a usable number. """ - return credits_to_microcredits(credits) + return credits_to_microcredits(credits, allow_rounding=allow_rounding) + + def from_microcredits(self, microcredits: int) -> Decimal: + """Convert an integer microcredits amount to credits, exactly. - def from_microcredits(self, microcredits: int) -> float: - """Convert an integer microcredits amount to credits. + Local and synchronous. Returns a :class:`~decimal.Decimal` — see + :meth:`Aleo.from_microcredits` for why a float will not do. Parameters ---------- diff --git a/sdk/python/aleo/facade/client.py b/sdk/python/aleo/facade/client.py index fbc0adfa..3f04a8df 100644 --- a/sdk/python/aleo/facade/client.py +++ b/sdk/python/aleo/facade/client.py @@ -7,10 +7,15 @@ """ from __future__ import annotations +from decimal import Decimal from typing import Any from .._client_common import AleoNetworkError -from .._facade_common import credits_to_microcredits, microcredits_to_credits +from .._facade_common import ( + CreditsAmount, + credits_to_microcredits, + microcredits_to_credits, +) from .provider import HTTPProvider # AleoNetworkClient imported at runtime (avoid circular at module-level) @@ -211,20 +216,41 @@ def get_balance(self, address: str) -> int: # ── Unit conversions ─────────────────────────────────────────────────── - def to_microcredits(self, credits: float | int) -> int: - """Convert a credits amount to integer microcredits. + def to_microcredits( + self, credits: CreditsAmount, *, allow_rounding: bool = False + ) -> int: + """Convert a credits amount to integer microcredits, exactly. - ``1 credit == 1_000_000 microcredits`` + ``1 credit == 1_000_000 microcredits``. Computed in decimal, so + ``1.005`` gives 1_005_000 rather than the 1_004_999 a binary float + multiply would truncate to. Parameters ---------- credits: - Credits amount as a float or integer (e.g. ``1.5``). + Credits amount. ``str`` and ``Decimal`` are exact; a ``float`` is + read as the decimal literal it prints as, so prefer ``"1.005"`` over + ``1.005`` when the amount comes from text. + allow_rounding: + Permit input finer than one microcredit, truncating toward zero. + Off by default, so precision loss raises instead of silently + underpaying. + + Raises + ------ + ValueError + If *credits* is finer than a microcredit and *allow_rounding* is + False, or is not a usable number. """ - return credits_to_microcredits(credits) + return credits_to_microcredits(credits, allow_rounding=allow_rounding) + + def from_microcredits(self, microcredits: int) -> Decimal: + """Convert an integer microcredits amount to credits, exactly. - def from_microcredits(self, microcredits: int) -> float: - """Convert an integer microcredits amount to credits. + Returns a :class:`~decimal.Decimal`, not a float — microcredits are a + ``u64`` and past 2**53 a float cannot hold the integer. It compares + equal to the obvious float, but mixing it into float arithmetic raises; + call ``float(...)`` deliberately if you want that. Parameters ---------- diff --git a/sdk/python/tests/test_facade_client.py b/sdk/python/tests/test_facade_client.py index f4a5d739..a5fef9c6 100644 --- a/sdk/python/tests/test_facade_client.py +++ b/sdk/python/tests/test_facade_client.py @@ -4,6 +4,8 @@ """ from __future__ import annotations +from decimal import Decimal + import pytest import responses as resp_lib @@ -312,6 +314,54 @@ def test_unit_conversion_round_trip() -> None: assert a.from_microcredits(a.to_microcredits(original)) == original +# Precision: these amounts silently lost value when the conversion went +# through binary floating point (1.005 truncated to 1_004_999), and a +# microcredit round-trip was not the identity for ~1.4% of values. + + +def test_to_microcredits_does_not_lose_a_microcredit() -> None: + a = Aleo(HTTPProvider(BASE)) + # float * 1e6 lands on 1004999.9999999999; int() then truncates down + assert a.to_microcredits(1.005) == 1_005_000 + assert a.to_microcredits("1.005") == 1_005_000 + assert a.to_microcredits(Decimal("1.005")) == 1_005_000 + + +def test_to_microcredits_rejects_sub_microcredit_precision() -> None: + a = Aleo(HTTPProvider(BASE)) + with pytest.raises(ValueError, match="finer than the chain"): + a.to_microcredits(0.9999999) + # opt in to the old truncating behaviour explicitly + assert a.to_microcredits(0.9999999, allow_rounding=True) == 999_999 + + +def test_to_microcredits_rejects_nonsense() -> None: + a = Aleo(HTTPProvider(BASE)) + for bad in ("abc", float("nan"), float("inf")): + with pytest.raises(ValueError): + a.to_microcredits(bad) + + +def test_from_microcredits_is_exact_past_float_range() -> None: + a = Aleo(HTTPProvider(BASE)) + # microcredits are u64; beyond 2**53 a float cannot hold the integer + for micro in (2**53 + 1, 2**64 - 1): + assert a.to_microcredits(a.from_microcredits(micro)) == micro + + +def test_microcredit_round_trip_is_identity() -> None: + a = Aleo(HTTPProvider(BASE)) + # 2884 of these failed when from_microcredits returned a float + for micro in range(0, 5_000): + assert a.to_microcredits(a.from_microcredits(micro)) == micro + + +def test_from_microcredits_still_compares_equal_to_float() -> None: + a = Aleo(HTTPProvider(BASE)) + assert a.from_microcredits(1_500_000) == 1.5 + assert float(a.from_microcredits(1_500_000)) == 1.5 + + # --------------------------------------------------------------------------- # is_valid_address # ---------------------------------------------------------------------------