Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 80 additions & 11 deletions sdk/python/aleo/_facade_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -46,27 +51,91 @@ 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
--------
>>> credits_to_microcredits(1)
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)
35 changes: 27 additions & 8 deletions sdk/python/aleo/facade/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
----------
Expand Down
42 changes: 34 additions & 8 deletions sdk/python/aleo/facade/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
----------
Expand Down
50 changes: 50 additions & 0 deletions sdk/python/tests/test_facade_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"""
from __future__ import annotations

from decimal import Decimal

import pytest
import responses as resp_lib

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading