From 56ac9328b56920b857fd576a0d4170d506b338aa Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 14 May 2026 07:56:36 -0700 Subject: [PATCH] feat: add usd_to_atomic helper Convert a USD amount to atomic units for a token with `decimals` places. Decimal-based with ROUND_HALF_UP so values at exactly half a base unit round away from zero, matching the cross-language Node sibling. Rejects negative, NaN, infinite, and unparseable inputs. Accepts str, float, int, and Decimal; whitespace-padded strings are trimmed. Tests lock 22 fixtures with hardcoded atomic values as the cross-language contract with @agent-score/commerce's usdToAtomic. Parametrized so multiple drifts surface independently. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/payment/__init__.py | 2 + agentscore_commerce/payment/amounts.py | 57 +++++++++ tests/test_amounts.py | 147 ++++++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 agentscore_commerce/payment/amounts.py create mode 100644 tests/test_amounts.py diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index e47d09f..32f82d0 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -1,5 +1,6 @@ """Payment helpers — networks/usdc/rails registries, paymentauth.org directive builders, dispatch, headers.""" +from agentscore_commerce.payment.amounts import usd_to_atomic from agentscore_commerce.payment.directive import ( BuildPaymentDirectiveInput, PaymentDirectiveInput, @@ -137,6 +138,7 @@ "register_x402_schemes_v1_v2", "settle_result_to_json_bytes", "settlement_override_header", + "usd_to_atomic", "validate_x402_network_config", "verify_x402_request", "www_authenticate_header", diff --git a/agentscore_commerce/payment/amounts.py b/agentscore_commerce/payment/amounts.py new file mode 100644 index 0000000..2d249fa --- /dev/null +++ b/agentscore_commerce/payment/amounts.py @@ -0,0 +1,57 @@ +"""USD ↔ atomic-unit conversion for token amounts. + +`usd_to_atomic(usd, decimals=6)` returns the integer atomic value of a USD +amount for a token with `decimals` places of precision (USDC is 6). Uses +``Decimal`` + ``ROUND_HALF_UP`` so a USD value at exactly half a base unit +rounds away from zero, matching the cross-language Node sibling. + +Rejects negative, NaN, and infinite inputs. Scientific-notation strings +(``"1e6"``) are accepted on the Python side via ``Decimal``; the Node sibling +rejects them and requires fixed notation, so cross-language byte-parity tests +fix on fixed-notation fixtures. +""" + +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation + + +def usd_to_atomic(usd: str | float | int | Decimal, *, decimals: int) -> int: + """Convert a USD amount to atomic units for a token with ``decimals`` places. + + Args: + usd: USD amount. Strings (``"1.23"``), ``float`` (``1.23``), ``int``, + and ``Decimal`` instances are accepted. The value is converted via + ``str()`` before parsing with ``Decimal``. + decimals: Number of decimal places in the atomic unit (6 for USDC, + 18 for ETH, etc.). Must be a non-negative ``int``. + + Returns: + Integer atomic units. ``1.23`` with ``decimals=6`` returns ``1_230_000``. + + Raises: + ValueError: if ``usd`` is negative, NaN, infinite, or unparseable, or + if ``decimals`` is not a non-negative ``int``. + """ + if not isinstance(decimals, int) or isinstance(decimals, bool) or decimals < 0: + msg = f"decimals must be a non-negative int, got {decimals!r}" + raise ValueError(msg) + + # Strip whitespace on string input so Python matches Node's `.trim()` behavior + # (Decimal itself rejects whitespace-padded strings with InvalidOperation). + raw = usd.strip() if isinstance(usd, str) else usd + try: + amount = Decimal(str(raw)) + except (InvalidOperation, ValueError) as exc: + msg = f"invalid usd value: {usd!r}" + raise ValueError(msg) from exc + + if not amount.is_finite(): + msg = f"usd must be finite, got {usd!r}" + raise ValueError(msg) + if amount < 0: + msg = f"usd must be non-negative, got {amount}" + raise ValueError(msg) + + scaled = (amount * (Decimal(10) ** decimals)).to_integral_value(rounding=ROUND_HALF_UP) + return int(scaled) diff --git a/tests/test_amounts.py b/tests/test_amounts.py new file mode 100644 index 0000000..3452ead --- /dev/null +++ b/tests/test_amounts.py @@ -0,0 +1,147 @@ +"""Tests for ``agentscore_commerce.payment.amounts.usd_to_atomic``. + +The fixture corpus below is locked as the cross-language contract with the +Node sibling at ``node-commerce/tests/payment/amounts.test.ts``. Both files +reference identical fixed-notation inputs + decimals + expected atomic values. +A drift in either language (rounding mode, encoding, edge-case handling) fails +that language's test against the locked value. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from agentscore_commerce.payment import usd_to_atomic + +# Cross-language fixtures: (input_string, decimals, expected_atomic). +# Inputs are fixed-notation strings so Python's Decimal and the Node sibling's +# regex-based parser produce identical results. +_FIXTURES = [ + # Plain whole + simple decimals + ("0", 6, 0), + ("1", 6, 1_000_000), + ("1.0", 6, 1_000_000), + ("1.00", 6, 1_000_000), + ("0.5", 6, 500_000), + ("10.00", 6, 10_000_000), + ("270.00", 6, 270_000_000), + # Exact decimal precision + ("1.234567", 6, 1_234_567), + # Round-half-up at the boundary (USDC tail of 5) + ("1.2345675", 6, 1_234_568), + ("1.2345674", 6, 1_234_567), + ("1.2345679", 6, 1_234_568), + # Sub-precision rounding + ("0.0000005", 6, 1), + ("0.0000004", 6, 0), + # Different decimals tail + ("1.23", 2, 123), + ("1.5", 0, 2), + ("1.4", 0, 1), + ("0.5", 0, 1), + ("0.4999999999", 0, 0), + ("0.5000000001", 0, 1), + # Leading-zero and trailing-dot forms + (".5", 6, 500_000), + ("5.", 6, 5_000_000), + ("001", 6, 1_000_000), +] + + +@pytest.mark.parametrize( + ("usd", "decimals", "expected"), + _FIXTURES, + ids=[f"{u!r}@{d}" for u, d, _ in _FIXTURES], +) +def test_locked_cross_language_fixture(usd: str, decimals: int, expected: int) -> None: + """Each fixture input maps to the locked cross-language atomic value.""" + assert usd_to_atomic(usd, decimals=decimals) == expected + + +def test_accepts_float_input() -> None: + """Float input is converted via ``str()`` then parsed by Decimal.""" + assert usd_to_atomic(1.23, decimals=6) == 1_230_000 + + +def test_accepts_decimal_input() -> None: + """``Decimal`` input is passed through (matches the float path's precision).""" + assert usd_to_atomic(Decimal("1.234567"), decimals=6) == 1_234_567 + + +def test_accepts_int_input() -> None: + """Plain ``int`` is treated as a whole-USD amount.""" + assert usd_to_atomic(5, decimals=6) == 5_000_000 + + +def test_zero_input_returns_zero() -> None: + assert usd_to_atomic("0", decimals=6) == 0 + assert usd_to_atomic(0, decimals=6) == 0 + assert usd_to_atomic(0.0, decimals=6) == 0 + + +def test_decimals_zero_returns_whole_dollars() -> None: + """``decimals=0`` returns the (rounded) whole-USD value.""" + assert usd_to_atomic("123.4", decimals=0) == 123 + assert usd_to_atomic("123.5", decimals=0) == 124 + + +def test_negative_string_rejected() -> None: + with pytest.raises(ValueError, match="non-negative"): + usd_to_atomic("-1.00", decimals=6) + + +def test_negative_float_rejected() -> None: + with pytest.raises(ValueError, match="non-negative"): + usd_to_atomic(-1.0, decimals=6) + + +def test_nan_rejected() -> None: + with pytest.raises(ValueError, match="finite"): + usd_to_atomic(float("nan"), decimals=6) + + +def test_positive_infinity_rejected() -> None: + with pytest.raises(ValueError, match="finite"): + usd_to_atomic(float("inf"), decimals=6) + + +def test_negative_infinity_rejected() -> None: + # Negative-infinity fails the finite check before the non-negative check; either error is OK. + with pytest.raises(ValueError): + usd_to_atomic(float("-inf"), decimals=6) + + +def test_empty_string_rejected() -> None: + with pytest.raises(ValueError, match="invalid usd value"): + usd_to_atomic("", decimals=6) + + +def test_garbage_string_rejected() -> None: + with pytest.raises(ValueError, match="invalid usd value"): + usd_to_atomic("abc", decimals=6) + with pytest.raises(ValueError, match="invalid usd value"): + usd_to_atomic("1.2.3", decimals=6) + + +def test_whitespace_padded_string_accepted() -> None: + """String input is trimmed so a leading/trailing space matches the Node sibling.""" + assert usd_to_atomic(" 1.00 ", decimals=6) == 1_000_000 + assert usd_to_atomic("\t0.50\n", decimals=6) == 500_000 + + +def test_negative_decimals_rejected() -> None: + with pytest.raises(ValueError, match="non-negative int"): + usd_to_atomic("1.00", decimals=-1) + + +def test_non_int_decimals_rejected() -> None: + with pytest.raises(ValueError, match="non-negative int"): + usd_to_atomic("1.00", decimals=6.0) # type: ignore[arg-type] + + +def test_bool_decimals_rejected() -> None: + """``bool`` is a subclass of ``int`` in Python; reject explicitly to avoid surprise.""" + with pytest.raises(ValueError, match="non-negative int"): + usd_to_atomic("1.00", decimals=True) # type: ignore[arg-type]